diff --git a/.eslintrc.js b/.eslintrc.js index 59f3401f..918865f4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { 'import/newline-after-import': 'error', 'import/no-default-export': 'off', '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': [ 'error', { @@ -67,4 +67,14 @@ module.exports = { es6: true, }, ignorePatterns: ['dist', 'node_modules'], + root: true, + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts'] + }, + 'import/resolver': { + typescript: {}, + exports: {}, + }, + }, }; diff --git a/.gitignore b/.gitignore index ec2a06da..6cb8c059 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ electron-builder.yml !.yarn/releases !.yarn/sdks !.yarn/versions +.vite-inspect diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d939b08f..3d558c99 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,18 +1,27 @@ +import { resolve } from 'node:path'; + import { defineConfig, defineViteConfig } from 'electron-vite'; import builtinModules from 'builtin-modules'; 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'; +const resolveAlias = { + '@': resolve(__dirname, './src'), + '@assets': resolve(__dirname, './assets'), +}; + export default defineConfig({ main: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('backend'), viteResolve({ - 'virtual:MainPlugins': pluginVirtualModuleGenerator('main'), - 'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'), + 'virtual:plugins': pluginVirtualModuleGenerator('main'), }), ], publicDir: 'assets', @@ -30,9 +39,15 @@ export default defineConfig({ input: './src/index.ts', }, }, + resolve: { + alias: resolveAlias, + }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/backend' }), + ); return commonConfig; } @@ -48,8 +63,9 @@ export default defineConfig({ preload: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('preload'), viteResolve({ - 'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'), + 'virtual:plugins': pluginVirtualModuleGenerator('preload'), }), ], build: { @@ -64,11 +80,17 @@ export default defineConfig({ rollupOptions: { external: ['electron', 'custom-electron-prompt', ...builtinModules], input: './src/preload.ts', - } + }, + }, + resolve: { + alias: resolveAlias, }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/preload' }), + ); return commonConfig; } @@ -84,8 +106,9 @@ export default defineConfig({ renderer: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('renderer'), viteResolve({ - 'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'), + 'virtual:plugins': pluginVirtualModuleGenerator('renderer'), }), ], root: './src/', @@ -104,9 +127,15 @@ export default defineConfig({ input: './src/index.html', }, }, + resolve: { + alias: resolveAlias, + }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/renderer' }), + ); return commonConfig; } diff --git a/package.json b/package.json index 29e4fa4d..d60d27f9 100644 --- a/package.json +++ b/package.json @@ -94,11 +94,12 @@ "test": "playwright test", "test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test", "build": "electron-vite build", + "vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect", "start": "electron-vite preview", "start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start", "dev": "electron-vite 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:linux": "pnpm clean && pnpm build && electron-builder --linux -p never", "dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never", @@ -117,7 +118,7 @@ }, "pnpm": { "overrides": { - "rollup": "4.4.1", + "rollup": "4.6.0", "node-gyp": "10.0.1", "xml2js": "0.6.2", "node-fetch": "3.3.2", @@ -128,7 +129,8 @@ "dependencies": { "@cliqz/adblocker-electron": "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/main": "0.12.0", "@foobar404/wave": "2.0.4", @@ -138,9 +140,10 @@ "async-mutex": "0.4.0", "butterchurn": "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", "dbus-next": "0.10.2", + "deepmerge-ts": "5.1.0", "electron-debug": "3.2.0", "electron-is": "3.0.0", "electron-localshortcut": "3.2.1", @@ -148,6 +151,7 @@ "electron-unhandled": "4.0.1", "electron-updater": "6.1.7", "fast-average-color": "9.4.0", + "fast-equals": "^5.0.1", "filenamify": "6.0.0", "howler": "2.2.4", "html-to-text": "9.0.5", @@ -155,18 +159,20 @@ "keyboardevents-areequal": "0.2.2", "node-html-parser": "6.1.11", "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", + "ts-morph": "^20.0.0", "vudio": "2.1.1", "x11": "2.3.0", "youtubei.js": "7.0.0" }, "devDependencies": { - "@playwright/test": "1.40.0", + "@playwright/test": "1.40.1", "@total-typescript/ts-reset": "0.5.1", "@types/electron-localshortcut": "3.1.3", "@types/howler": "2.2.11", "@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", "builtin-modules": "^3.3.0", "cross-env": "7.0.3", @@ -176,15 +182,18 @@ "electron-devtools-installer": "3.2.0", "electron-vite": "1.0.29", "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-prettier": "5.0.1", "glob": "10.3.10", "node-gyp": "10.0.1", - "playwright": "1.40.0", + "playwright": "1.40.1", "rollup": "4.6.0", "typescript": "5.3.2", "utf-8-validate": "6.0.3", "vite": "4.5.0", + "vite-plugin-inspect": "^0.7.42", "vite-plugin-resolve": "2.5.1", "ws": "8.14.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 727a642d..dcaa5a49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - rollup: 4.4.1 + rollup: 4.6.0 node-gyp: 10.0.1 xml2js: 0.6.2 node-fetch: 3.3.2 @@ -19,9 +19,12 @@ dependencies: '@cliqz/adblocker-electron-preload': specifier: 1.26.12 version: 1.26.12(electron@27.1.2) - '@fastify/deepmerge': - specifier: 1.3.0 - version: 1.3.0 + '@electron-toolkit/tsconfig': + specifier: 1.0.1 + version: 1.0.1(@types/node@20.8.6) + '@electron/remote': + specifier: 2.1.0 + version: 2.1.0(electron@27.1.2) '@ffmpeg.wasm/core-mt': specifier: 0.12.0 version: 0.12.0 @@ -50,14 +53,17 @@ dependencies: specifier: 3.0.0-beta.4 version: 3.0.0-beta.4 conf: - specifier: 12.0.0 - version: 12.0.0 + specifier: 10.2.0 + version: 10.2.0 custom-electron-prompt: specifier: 1.5.7 version: 1.5.7(electron@27.1.2) dbus-next: specifier: 0.10.2 version: 0.10.2 + deepmerge-ts: + specifier: 5.1.0 + version: 5.1.0 electron-debug: specifier: 3.2.0 version: 3.2.0 @@ -79,6 +85,9 @@ dependencies: fast-average-color: specifier: 9.4.0 version: 9.4.0 + fast-equals: + specifier: ^5.0.1 + version: 5.0.1 filenamify: specifier: 6.0.0 version: 6.0.0 @@ -100,9 +109,15 @@ dependencies: node-id3: specifier: 0.2.6 version: 0.2.6 + serve: + specifier: ^14.2.1 + version: 14.2.1 simple-youtube-age-restriction-bypass: specifier: git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8 version: github.com/organization/Simple-YouTube-Age-Restriction-Bypass/816a882c68fcfe6cdd9410a6877b88093ed15b28 + ts-morph: + specifier: ^20.0.0 + version: 20.0.0 vudio: specifier: 2.1.1 version: 2.1.1 @@ -115,8 +130,8 @@ dependencies: devDependencies: '@playwright/test': - specifier: 1.40.0 - version: 1.40.0 + specifier: 1.40.1 + version: 1.40.1 '@total-typescript/ts-reset': specifier: 0.5.1 version: 0.5.1 @@ -130,8 +145,8 @@ devDependencies: specifier: 9.0.4 version: 9.0.4 '@typescript-eslint/eslint-plugin': - specifier: 6.12.0 - version: 6.12.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0)(typescript@5.3.2) + specifier: 6.13.0 + version: 6.13.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0)(typescript@5.3.2) bufferutil: specifier: 4.0.8 version: 4.0.8 @@ -159,9 +174,15 @@ devDependencies: eslint: specifier: 8.54.0 version: 8.54.0 + eslint-import-resolver-exports: + specifier: 1.0.0-beta.5 + version: 1.0.0-beta.5(eslint-plugin-import@2.29.0)(eslint@8.54.0) + eslint-import-resolver-typescript: + specifier: 3.6.1 + version: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.54.0) eslint-plugin-import: specifier: 2.29.0 - version: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0) + version: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0) eslint-plugin-prettier: specifier: 5.0.1 version: 5.0.1(eslint@8.54.0)(prettier@3.0.3) @@ -172,11 +193,11 @@ devDependencies: specifier: 10.0.1 version: 10.0.1 playwright: - specifier: 1.40.0 - version: 1.40.0 + specifier: 1.40.1 + version: 1.40.1 rollup: - specifier: 4.4.1 - version: 4.4.1 + specifier: 4.6.0 + version: 4.6.0 typescript: specifier: 5.3.2 version: 5.3.2 @@ -185,7 +206,10 @@ devDependencies: version: 6.0.3 vite: specifier: 4.5.0 - version: 4.5.0 + version: 4.5.0(@types/node@20.8.6) + vite-plugin-inspect: + specifier: ^0.7.42 + version: 0.7.42(rollup@4.6.0)(vite@4.5.0) vite-plugin-resolve: specifier: 2.5.1 version: 2.5.1 @@ -212,6 +236,10 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true + /@antfu/utils@0.7.6: + resolution: {integrity: sha512-pvFiLP2BeOKA/ZOS6jxx4XhKzdVLHDhGlFEaZ2flWWYf2xOqVniqpk38I04DFRyz+L0ASggl7SkItTc+ZLju4w==} + dev: true + /@assemblyscript/loader@0.17.14: resolution: {integrity: sha512-+PVTOfla/0XMLRTQLJFPg4u40XcdTfon6GGea70hBGi8Pd7ZymIXyVUR+vK8wt5Jb4MVKTKPIz43Myyebw5mZA==} dev: false @@ -480,6 +508,14 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /@electron-toolkit/tsconfig@1.0.1(@types/node@20.8.6): + resolution: {integrity: sha512-M0Mol3odspvtCuheyujLNAW7bXq7KFNYVMRtpjFa4ZfES4MuklXBC7Nli/omvc+PRKlrklgAGx3l4VakjNo8jg==} + peerDependencies: + '@types/node': '*' + dependencies: + '@types/node': 20.8.6 + dev: false + /@electron/asar@3.2.7: resolution: {integrity: sha512-8FaSCAIiZGYFWyjeevPQt+0e9xCK9YmJ2Rjg5SXgdsXon6cRnU0Yxnbe6CvJbQn26baifur2Y2G5EBayRIsjyg==} engines: {node: '>=10.12.0'} @@ -532,6 +568,14 @@ packages: - supports-color dev: true + /@electron/remote@2.1.0(electron@27.1.2): + resolution: {integrity: sha512-38jzz2beoYTo0DNS+aoaGyLS/fHeNTAc1Aom6HlYsxKnvVWjcg4xriC7J2IUkYSEDHGKX/D7jUst+mH4dHR6QA==} + peerDependencies: + electron: '>= 13.0.0' + dependencies: + electron: 27.1.2 + dev: false + /@electron/universal@2.0.0: resolution: {integrity: sha512-Kps3RG6mXtEvoGYmpazMRRTZ1Zklba7oeYiaSaVCR18iKyP0A7WV9t1w3hu1PlzLnGunLJ2I10WvJC++KNbkIQ==} engines: {node: '>=16.4'} @@ -787,10 +831,6 @@ packages: engines: {node: '>=14'} dev: false - /@fastify/deepmerge@1.3.0: - resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} - dev: false - /@ffmpeg.wasm/core-mt@0.12.0: resolution: {integrity: sha512-M9pjL7JQX4AYl3WI8vGcPGPTz/O7JmhW8ac/fHA3oXTxoRAPwYSY/OsY1N9C0XahIM0+fxa1QSLN9Ekz8sBM/Q==} dev: false @@ -911,12 +951,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -924,7 +962,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@nornagon/put@0.0.8: resolution: {integrity: sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==} @@ -967,12 +1004,16 @@ packages: tslib: 2.6.2 dev: true - /@playwright/test@1.40.0: - resolution: {integrity: sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==} + /@playwright/test@1.40.1: + resolution: {integrity: sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==} engines: {node: '>=16'} hasBin: true dependencies: - playwright: 1.40.0 + playwright: 1.40.1 + dev: true + + /@polka/url@1.0.0-next.23: + resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==} dev: true /@remusao/guess-url-type@1.2.1: @@ -1004,96 +1045,111 @@ packages: resolution: {integrity: sha512-yvwa+aCyYI/UjeD39BnpMypG8N06l86wIDW1/PAc6ihBRnodIfZDwccxQN3n1t74wduzaz74m4ZMHZnB06567Q==} dev: false - /@rollup/rollup-android-arm-eabi@4.4.1: - resolution: {integrity: sha512-Ss4suS/sd+6xLRu+MLCkED2mUrAyqHmmvZB+zpzZ9Znn9S8wCkTQCJaQ8P8aHofnvG5L16u9MVnJjCqioPErwQ==} + /@rollup/pluginutils@5.0.5(rollup@4.6.0): + resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: 4.6.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 4.6.0 + dev: true + + /@rollup/rollup-android-arm-eabi@4.6.0: + resolution: {integrity: sha512-keHkkWAe7OtdALGoutLY3utvthkGF+Y17ws9LYT8pxMBYXaCoH/8dXS2uzo6e8+sEhY7y/zi5RFo22Dy2lFpDw==} cpu: [arm] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-android-arm64@4.4.1: - resolution: {integrity: sha512-sRSkGTvGsARwWd7TzC8LKRf8FiPn7257vd/edzmvG4RIr9x68KBN0/Ek48CkuUJ5Pj/Dp9vKWv6PEupjKWjTYA==} + /@rollup/rollup-android-arm64@4.6.0: + resolution: {integrity: sha512-y3Kt+34smKQNWilicPbBz/MXEY7QwDzMFNgwEWeYiOhUt9MTWKjHqe3EVkXwT2fR7izOvHpDWZ0o2IyD9SWX7A==} cpu: [arm64] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-arm64@4.4.1: - resolution: {integrity: sha512-nz0AiGrrXyaWpsmBXUGOBiRDU0wyfSXbFuF98pPvIO8O6auQsPG6riWsfQqmCCC5FNd8zKQ4JhgugRNAkBJ8mQ==} + /@rollup/rollup-darwin-arm64@4.6.0: + resolution: {integrity: sha512-oLzzxcUIHltHxOCmaXl+pkIlU+uhSxef5HfntW7RsLh1eHm+vJzjD9Oo4oUKso4YuP4PpbFJNlZjJuOrxo8dPg==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-x64@4.4.1: - resolution: {integrity: sha512-Ogqvf4/Ve/faMaiPRvzsJEqajbqs00LO+8vtrPBVvLgdw4wBg6ZDXdkDAZO+4MLnrc8mhGV6VJAzYScZdPLtJg==} + /@rollup/rollup-darwin-x64@4.6.0: + resolution: {integrity: sha512-+ANnmjkcOBaV25n0+M0Bere3roeVAnwlKW65qagtuAfIxXF9YxUneRyAn/RDcIdRa7QrjRNJL3jR7T43ObGe8Q==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.4.1: - resolution: {integrity: sha512-9zc2tqlr6HfO+hx9+wktUlWTRdje7Ub15iJqKcqg5uJZ+iKqmd2CMxlgPpXi7+bU7bjfDIuvCvnGk7wewFEhCg==} + /@rollup/rollup-linux-arm-gnueabihf@4.6.0: + resolution: {integrity: sha512-tBTSIkjSVUyrekddpkAqKOosnj1Fc0ZY0rJL2bIEWPKqlEQk0paORL9pUIlt7lcGJi3LzMIlUGXvtNi1Z6MOCQ==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-gnu@4.4.1: - resolution: {integrity: sha512-phLb1fN3rq2o1j1v+nKxXUTSJnAhzhU0hLrl7Qzb0fLpwkGMHDem+o6d+ZI8+/BlTXfMU4kVWGvy6g9k/B8L6Q==} + /@rollup/rollup-linux-arm64-gnu@4.6.0: + resolution: {integrity: sha512-Ed8uJI3kM11de9S0j67wAV07JUNhbAqIrDYhQBrQW42jGopgheyk/cdcshgGO4fW5Wjq97COCY/BHogdGvKVNQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-musl@4.4.1: - resolution: {integrity: sha512-M2sDtw4tf57VPSjbTAN/lz1doWUqO2CbQuX3L9K6GWIR5uw9j+ROKCvvUNBY8WUbMxwaoc8mH9HmmBKsLht7+w==} + /@rollup/rollup-linux-arm64-musl@4.6.0: + resolution: {integrity: sha512-mZoNQ/qK4D7SSY8v6kEsAAyDgznzLLuSFCA3aBHZTmf3HP/dW4tNLTtWh9+LfyO0Z1aUn+ecpT7IQ3WtIg3ViQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-gnu@4.4.1: - resolution: {integrity: sha512-mHIlRLX+hx+30cD6c4BaBOsSqdnCE4ok7/KDvjHYAHoSuveoMMxIisZFvcLhUnyZcPBXDGZTuBoalcuh43UfQQ==} + /@rollup/rollup-linux-x64-gnu@4.6.0: + resolution: {integrity: sha512-rouezFHpwCqdEXsqAfNsTgSWO0FoZ5hKv5p+TGO5KFhyN/dvYXNMqMolOb8BkyKcPqjYRBeT+Z6V3aM26rPaYg==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-musl@4.4.1: - resolution: {integrity: sha512-tB+RZuDi3zxFx7vDrjTNGVLu2KNyzYv+UY8jz7e4TMEoAj7iEt8Qk6xVu6mo3pgjnsHj6jnq3uuRsHp97DLwOA==} + /@rollup/rollup-linux-x64-musl@4.6.0: + resolution: {integrity: sha512-Bbm+fyn3S6u51urfj3YnqBXg5vI2jQPncRRELaucmhBVyZkbWClQ1fEsRmdnCPpQOQfkpg9gZArvtMVkOMsh1w==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-arm64-msvc@4.4.1: - resolution: {integrity: sha512-Hdn39PzOQowK/HZzYpCuZdJC91PE6EaGbTe2VCA9oq2u18evkisQfws0Smh9QQGNNRa/T7MOuGNQoLeXhhE3PQ==} + /@rollup/rollup-win32-arm64-msvc@4.6.0: + resolution: {integrity: sha512-+MRMcyx9L2kTrTUzYmR61+XVsliMG4odFb5UmqtiT8xOfEicfYAGEuF/D1Pww1+uZkYhBqAHpvju7VN+GnC3ng==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-ia32-msvc@4.4.1: - resolution: {integrity: sha512-tLpKb1Elm9fM8c5w3nl4N1eLTP4bCqTYw9tqUBxX8/hsxqHO3dxc2qPbZ9PNkdK4tg4iLEYn0pOUnVByRd2CbA==} + /@rollup/rollup-win32-ia32-msvc@4.6.0: + resolution: {integrity: sha512-rxfeE6K6s/Xl2HGeK6cO8SiQq3k/3BYpw7cfhW5Bk2euXNEpuzi2cc7llxx1si1QgwfjNtdRNTGqdBzGlFZGFw==} cpu: [ia32] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-x64-msvc@4.4.1: - resolution: {integrity: sha512-eAhItDX9yQtZVM3yvXS/VR3qPqcnXvnLyx1pLXl4JzyNMBNO3KC986t/iAg2zcMzpAp9JSvxB5VZGnBiNoA98w==} + /@rollup/rollup-win32-x64-msvc@4.6.0: + resolution: {integrity: sha512-QqmCsydHS172Y0Kc13bkMXvipbJSvzeglBncJG3LsYJSiPlxYACz7MmJBs4A8l1oU+jfhYEIC/+AUSlvjmiX/g==} cpu: [x64] os: [win32] requiresBuild: true @@ -1126,6 +1182,15 @@ packages: resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==} dev: true + /@ts-morph/common@0.21.0: + resolution: {integrity: sha512-ES110Mmne5Vi4ypUKrtVQfXFDtCsDXiUiGxF6ILVlE90dDD4fdpC1LSjydl/ml7xJWKSDZwUYD2zkOePMSrPBA==} + dependencies: + fast-glob: 3.3.1 + minimatch: 7.4.6 + mkdirp: 2.1.6 + path-browserify: 1.0.1 + dev: false + /@types/cacheable-request@6.0.3: resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} dependencies: @@ -1155,6 +1220,10 @@ packages: - supports-color dev: true + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + /@types/filesystem@0.0.33: resolution: {integrity: sha512-2KedRPzwu2K528vFkoXnnWdsG0MtUwPjuA7pRy4vKxlxHEe8qUDZibYHXJKZZr2Cl/ELdCWYqyb/MKwsUuzBWw==} dependencies: @@ -1254,8 +1323,8 @@ packages: '@types/node': 20.8.6 optional: true - /@typescript-eslint/eslint-plugin@6.12.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==} + /@typescript-eslint/eslint-plugin@6.13.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0)(typescript@5.3.2): + resolution: {integrity: sha512-HTvbSd0JceI2GW5DHS3R9zbarOqjkM9XDR7zL8eCsBUO/eSiHcoNE7kSL5sjGXmVa9fjH5LCfHDXNnH4QLp7tQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -1267,10 +1336,10 @@ packages: dependencies: '@eslint-community/regexpp': 4.9.1 '@typescript-eslint/parser': 6.7.5(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/type-utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/scope-manager': 6.13.0 + '@typescript-eslint/type-utils': 6.13.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/utils': 6.13.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/visitor-keys': 6.13.0 debug: 4.3.4 eslint: 8.54.0 graphemer: 1.4.0 @@ -1304,12 +1373,12 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@6.12.0: - resolution: {integrity: sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==} + /@typescript-eslint/scope-manager@6.13.0: + resolution: {integrity: sha512-2x0K2/CujsokIv+LN2T0l5FVDMtsCjkUyYtlcY4xxnxLAW+x41LXr16duoicHpGtLhmtN7kqvuFJ3zbz00Ikhw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/types': 6.13.0 + '@typescript-eslint/visitor-keys': 6.13.0 dev: true /@typescript-eslint/scope-manager@6.7.5: @@ -1320,8 +1389,8 @@ packages: '@typescript-eslint/visitor-keys': 6.7.5 dev: true - /@typescript-eslint/type-utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==} + /@typescript-eslint/type-utils@6.13.0(eslint@8.54.0)(typescript@5.3.2): + resolution: {integrity: sha512-YHufAmZd/yP2XdoD3YeFEjq+/Tl+myhzv+GJHSOz+ro/NFGS84mIIuLU3pVwUcauSmwlCrVXbBclkn1HfjY0qQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1330,8 +1399,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/typescript-estree': 6.13.0(typescript@5.3.2) + '@typescript-eslint/utils': 6.13.0(eslint@8.54.0)(typescript@5.3.2) debug: 4.3.4 eslint: 8.54.0 ts-api-utils: 1.0.3(typescript@5.3.2) @@ -1340,8 +1409,8 @@ packages: - supports-color dev: true - /@typescript-eslint/types@6.12.0: - resolution: {integrity: sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==} + /@typescript-eslint/types@6.13.0: + resolution: {integrity: sha512-oXg7DFxx/GmTrKXKKLSoR2rwiutOC7jCQ5nDH5p5VS6cmHE1TcPTaYQ0VPSSUvj7BnNqCgQ/NXcTBxn59pfPTQ==} engines: {node: ^16.0.0 || >=18.0.0} dev: true @@ -1350,8 +1419,8 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.12.0(typescript@5.3.2): - resolution: {integrity: sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==} + /@typescript-eslint/typescript-estree@6.13.0(typescript@5.3.2): + resolution: {integrity: sha512-IT4O/YKJDoiy/mPEDsfOfp+473A9GVqXlBKckfrAOuVbTqM8xbc0LuqyFCcgeFWpqu3WjQexolgqN2CuWBYbog==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -1359,8 +1428,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/types': 6.13.0 + '@typescript-eslint/visitor-keys': 6.13.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -1392,8 +1461,8 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==} + /@typescript-eslint/utils@6.13.0(eslint@8.54.0)(typescript@5.3.2): + resolution: {integrity: sha512-V+txaxARI8yznDkcQ6FNRXxG+T37qT3+2NsDTZ/nKLxv6VfGrRhTnuvxPUxpVuWWr+eVeIxU53PioOXbz8ratQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1401,9 +1470,9 @@ packages: '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) '@types/json-schema': 7.0.13 '@types/semver': 7.5.3 - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) + '@typescript-eslint/scope-manager': 6.13.0 + '@typescript-eslint/types': 6.13.0 + '@typescript-eslint/typescript-estree': 6.13.0(typescript@5.3.2) eslint: 8.54.0 semver: 7.5.4 transitivePeerDependencies: @@ -1411,11 +1480,11 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys@6.12.0: - resolution: {integrity: sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==} + /@typescript-eslint/visitor-keys@6.13.0: + resolution: {integrity: sha512-UQklteCEMCRoq/1UhKFZsHv5E4dN1wQSzJoxTfABasWk1HgJRdg1xNUve/Kv/Sdymt4x+iEzpESOqRFlQr/9Aw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 + '@typescript-eslint/types': 6.13.0 eslint-visitor-keys: 3.4.3 dev: true @@ -1449,10 +1518,22 @@ packages: requiresBuild: true dev: true + /@zeit/schemas@2.29.0: + resolution: {integrity: sha512-g5QiLIfbg3pLuYUJPlisNKY+epQJTcMDsOnVNkscrDP1oi7vmJnzOANYJI/1pZcVJ6umUkBv3aFtlg1UvUHGzA==} + dev: false + /abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + /accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: false + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1526,6 +1607,15 @@ packages: uri-js: 4.4.1 dev: true + /ajv@8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: false + /ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} dependencies: @@ -1535,6 +1625,12 @@ packages: uri-js: 4.4.1 dev: false + /ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + dependencies: + string-width: 4.2.3 + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1600,6 +1696,14 @@ packages: - supports-color dev: true + /arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: false + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1716,13 +1820,6 @@ packages: engines: {node: '>=10.12.0'} dev: false - /atomically@2.0.2: - resolution: {integrity: sha512-Xfmb4q5QV7uqTlVdMSTtO5eF4DCHfNOdaPyKlbFShkzeNP+3lj3yjjcbdjSmEY4+pDBKJ9g26aP+ImTe88UHoQ==} - dependencies: - stubborn-fs: 1.2.5 - when-exit: 2.1.1 - dev: false - /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -1777,6 +1874,20 @@ packages: requiresBuild: true optional: true + /boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + dev: false + /bplist-parser@0.2.0: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} @@ -1789,7 +1900,6 @@ packages: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - dev: true /brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} @@ -1801,7 +1911,6 @@ packages: engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browser-extension-url-match@1.0.0: resolution: {integrity: sha512-LfIs9SYgPjYksjxkgOVYZhxMIroR56isQB3YHTAmzunWuT9qrH6Fxt7TD9/s9MoKo7GP37JZbLlZhL9vwQAk3w==} @@ -1901,6 +2010,11 @@ packages: eel-wasm: 0.0.15 dev: false + /bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + dev: false + /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1965,10 +2079,22 @@ packages: engines: {node: '>=10'} dev: true + /camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + dev: false + /caniuse-lite@1.0.30001561: resolution: {integrity: sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==} dev: true + /chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + dependencies: + chalk: 4.1.2 + dev: false + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1984,7 +2110,11 @@ packages: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - dev: true + + /chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} @@ -2010,6 +2140,11 @@ packages: escape-string-regexp: 5.0.0 dev: true + /cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + dev: false + /cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -2020,6 +2155,15 @@ packages: dev: true optional: true + /clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + dev: false + /cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2034,6 +2178,10 @@ packages: dependencies: mimic-response: 1.0.1 + /code-block-writer@12.0.0: + resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2071,9 +2219,30 @@ packages: engines: {node: '>=0.10.0'} dev: true + /compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /compression@1.7.4: + resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} + engines: {node: '>= 0.8.0'} + dependencies: + accepts: 1.3.8 + bytes: 3.0.0 + compressible: 2.0.18 + debug: 2.6.9 + on-headers: 1.0.2 + safe-buffer: 5.1.2 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true /conf@10.2.0: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} @@ -2091,21 +2260,6 @@ packages: semver: 7.5.4 dev: false - /conf@12.0.0: - resolution: {integrity: sha512-fIWyWUXrJ45cHCIQX+Ck1hrZDIf/9DR0P0Zewn3uNht28hbt5OfGUq8rRWsxi96pZWPyBEd0eY9ama01JTaknA==} - engines: {node: '>=18'} - dependencies: - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) - atomically: 2.0.2 - debounce-fn: 5.1.2 - dot-prop: 8.0.2 - env-paths: 3.0.0 - json-schema-typed: 8.0.1 - semver: 7.5.4 - uint8array-extras: 0.3.0 - dev: false - /config-file-ts@0.2.4: resolution: {integrity: sha512-cKSW0BfrSaAUnxpgvpXPLaaW/umg4bqg4k3GO1JqlRfpx+d5W0GDXznCMkWotJQek5Mmz1MJVChQnz3IVaeMZQ==} dependencies: @@ -2113,6 +2267,11 @@ packages: typescript: 4.9.5 dev: true + /content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + dev: false + /convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true @@ -2202,11 +2361,15 @@ packages: mimic-fn: 3.1.0 dev: false - /debounce-fn@5.1.2: - resolution: {integrity: sha512-Sr4SdOZ4vw6eQDvPYNxHogvrxmCIld/VenC5JbNrFwMiwd7lY/Z18ZFfo+EWNG4DD9nFlAujWAo/wGuOPHmy5A==} - engines: {node: '>=12'} + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: - mimic-fn: 4.0.0 + ms: 2.0.0 dev: false /debug@3.2.7: @@ -2278,10 +2441,20 @@ packages: which-typed-array: 1.1.11 dev: false + /deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge-ts@5.1.0: + resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} + engines: {node: '>=16.0.0'} + dev: false + /deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -2457,13 +2630,6 @@ packages: is-obj: 2.0.0 dev: false - /dot-prop@8.0.2: - resolution: {integrity: sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ==} - engines: {node: '>=16'} - dependencies: - type-fest: 3.13.1 - dev: false - /dotenv-expand@5.1.0: resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} dev: true @@ -2635,7 +2801,7 @@ packages: esbuild: 0.18.20 magic-string: 0.30.5 picocolors: 1.0.0 - vite: 4.5.0 + vite: 4.5.0(@types/node@20.8.6) transitivePeerDependencies: - supports-color dev: true @@ -2670,6 +2836,14 @@ packages: dependencies: once: 1.4.0 + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + /ensure-error@2.1.0: resolution: {integrity: sha512-+BMSJHw9gxiJAAp2ZR1E0TNcL09dD3lOvkl7WVm4+Y6xnes/pMetP/TzCHiDduh8ihNDjbGfuYxl7l4PA1xZ8A==} engines: {node: '>=8'} @@ -2684,11 +2858,6 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - /env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dev: false - /err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -2698,6 +2867,10 @@ packages: is-arrayish: 0.2.1 dev: true + /error-stack-parser-es@0.1.1: + resolution: {integrity: sha512-g/9rfnvnagiNf+DRMHEVGuGuIBlCIMDFoTA616HaP2l9PlCjGjVhD98PNbVSJvmK4TttqT5mV5tInMhoFgi+aA==} + dev: true + /es-abstract@1.22.2: resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} @@ -2835,6 +3008,17 @@ packages: engines: {node: '>=12'} dev: true + /eslint-import-resolver-exports@1.0.0-beta.5(eslint-plugin-import@2.29.0)(eslint@8.54.0): + resolution: {integrity: sha512-o6t0w7muUpXr7MkUVzD5igQoDfAQvTmcPp8HEAJdNF8eOuAO+yn6I/TTyMxz9ecCwzX7e02vzlkHURoScUuidg==} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + dependencies: + eslint: 8.54.0 + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0) + resolve.exports: 2.0.2 + dev: true + /eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} dependencies: @@ -2845,7 +3029,30 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0): + /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.54.0): + resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + dependencies: + debug: 4.3.4 + enhanced-resolve: 5.15.0 + eslint: 8.54.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0) + fast-glob: 3.3.1 + get-tsconfig: 4.7.2 + is-core-module: 2.13.1 + is-glob: 4.0.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + dev: true + + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -2870,11 +3077,12 @@ packages: debug: 3.2.7 eslint: 8.54.0 eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.54.0) transitivePeerDependencies: - supports-color dev: true - /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.7.5)(eslint@8.54.0): + /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0): resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} engines: {node: '>=4'} peerDependencies: @@ -2893,7 +3101,7 @@ packages: doctrine: 2.1.0 eslint: 8.54.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.54.0) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -3017,6 +3225,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3047,7 +3259,6 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 strip-final-newline: 2.0.0 - dev: true /execa@7.2.0: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} @@ -3103,6 +3314,11 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3112,7 +3328,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3123,11 +3338,16 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-url-parser@1.1.3: + resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} + dependencies: + punycode: 1.4.1 + dev: false + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -3178,7 +3398,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} @@ -3359,7 +3578,6 @@ packages: /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - dev: true /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} @@ -3369,12 +3587,17 @@ packages: get-intrinsic: 1.2.1 dev: true + /get-tsconfig@4.7.2: + resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -3503,7 +3726,6 @@ packages: /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true /has-property-descriptors@1.0.0: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} @@ -3628,7 +3850,6 @@ packages: /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - dev: true /human-signals@4.3.1: resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} @@ -3706,6 +3927,10 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: true + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: false + /internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} @@ -3775,7 +4000,6 @@ packages: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true - dev: true /is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} @@ -3786,7 +4010,6 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} @@ -3797,7 +4020,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} @@ -3828,7 +4050,6 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} @@ -3855,6 +4076,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -3874,7 +4100,6 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -3925,7 +4150,6 @@ packages: engines: {node: '>=8'} dependencies: is-docker: 2.2.1 - dev: true /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -4016,10 +4240,6 @@ packages: resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} dev: false - /json-schema-typed@8.0.1: - resolution: {integrity: sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==} - dev: false - /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true @@ -4236,12 +4456,10 @@ packages: /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: true /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -4249,12 +4467,23 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true + + /mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + dev: false /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + /mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.33.0 + dev: false + /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} @@ -4279,6 +4508,7 @@ packages: /mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + dev: true /mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} @@ -4297,7 +4527,6 @@ packages: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 - dev: true /minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -4306,6 +4535,13 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch@7.4.6: + resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -4323,7 +4559,6 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} @@ -4392,6 +4627,21 @@ packages: engines: {node: '>=10'} hasBin: true + /mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + dev: false + + /mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: false + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -4504,7 +4754,6 @@ packages: engines: {node: '>=8'} dependencies: path-key: 3.1.1 - dev: true /npm-run-path@5.1.0: resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} @@ -4570,6 +4819,11 @@ packages: es-abstract: 1.22.2 dev: true + /on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -4693,6 +4947,10 @@ packages: peberminta: 0.9.0 dev: false + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + dev: false + /path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4708,6 +4966,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + dev: false + /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4728,6 +4990,10 @@ packages: lru-cache: 10.0.1 minipass: 7.0.4 + /path-to-regexp@2.2.1: + resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4753,7 +5019,6 @@ packages: /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} @@ -4762,18 +5027,18 @@ packages: find-up: 3.0.0 dev: false - /playwright-core@1.40.0: - resolution: {integrity: sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==} + /playwright-core@1.40.1: + resolution: {integrity: sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==} engines: {node: '>=16'} hasBin: true dev: true - /playwright@1.40.0: - resolution: {integrity: sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==} + /playwright@1.40.1: + resolution: {integrity: sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==} engines: {node: '>=16'} hasBin: true dependencies: - playwright-core: 1.40.0 + playwright-core: 1.40.1 optionalDependencies: fsevents: 2.3.2 dev: true @@ -4843,18 +5108,36 @@ packages: end-of-stream: 1.4.4 once: 1.4.0 + /punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + dev: false + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + dev: false + + /rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + dev: false + /read-config-file@6.3.2: resolution: {integrity: sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==} engines: {node: '>=12.0.0'} @@ -4922,6 +5205,20 @@ packages: define-properties: 1.2.1 set-function-name: 2.0.1 + /registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + dev: false + + /registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + dependencies: + rc: 1.2.8 + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4940,6 +5237,15 @@ packages: engines: {node: '>=4'} dev: true + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + + /resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + dev: true + /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -4961,7 +5267,6 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -4983,23 +5288,23 @@ packages: sprintf-js: 1.1.3 optional: true - /rollup@4.4.1: - resolution: {integrity: sha512-idZzrUpWSblPJX66i+GzrpjKE3vbYrlWirUHteoAbjKReZwa0cohAErOYA5efoMmNCdvG9yrJS+w9Kl6csaH4w==} + /rollup@4.6.0: + resolution: {integrity: sha512-R8i5Her4oO1LiMQ3jKf7MUglYV/mhQ5g5OKeld5CnkmPdIGo79FDDQYqPhq/PCVuTQVuxsWgIbDy9F+zdHn80w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.4.1 - '@rollup/rollup-android-arm64': 4.4.1 - '@rollup/rollup-darwin-arm64': 4.4.1 - '@rollup/rollup-darwin-x64': 4.4.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.4.1 - '@rollup/rollup-linux-arm64-gnu': 4.4.1 - '@rollup/rollup-linux-arm64-musl': 4.4.1 - '@rollup/rollup-linux-x64-gnu': 4.4.1 - '@rollup/rollup-linux-x64-musl': 4.4.1 - '@rollup/rollup-win32-arm64-msvc': 4.4.1 - '@rollup/rollup-win32-ia32-msvc': 4.4.1 - '@rollup/rollup-win32-x64-msvc': 4.4.1 + '@rollup/rollup-android-arm-eabi': 4.6.0 + '@rollup/rollup-android-arm64': 4.6.0 + '@rollup/rollup-darwin-arm64': 4.6.0 + '@rollup/rollup-darwin-x64': 4.6.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.6.0 + '@rollup/rollup-linux-arm64-gnu': 4.6.0 + '@rollup/rollup-linux-arm64-musl': 4.6.0 + '@rollup/rollup-linux-x64-gnu': 4.6.0 + '@rollup/rollup-linux-x64-musl': 4.6.0 + '@rollup/rollup-win32-arm64-msvc': 4.6.0 + '@rollup/rollup-win32-ia32-msvc': 4.6.0 + '@rollup/rollup-win32-x64-msvc': 4.6.0 fsevents: 2.3.3 dev: true @@ -5014,7 +5319,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} @@ -5028,7 +5332,6 @@ packages: /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: true /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5096,6 +5399,39 @@ packages: type-fest: 0.20.2 dev: false + /serve-handler@6.1.5: + resolution: {integrity: sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==} + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + fast-url-parser: 1.1.3 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 2.2.1 + range-parser: 1.2.0 + dev: false + + /serve@14.2.1: + resolution: {integrity: sha512-48er5fzHh7GCShLnNyPBRPEjs2I6QBozeGr02gaacROiyS/8ARADlj595j39iZXAqBbJHH/ivJJyPRWY9sQWZA==} + engines: {node: '>= 14'} + hasBin: true + dependencies: + '@zeit/schemas': 2.29.0 + ajv: 8.11.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.7.4 + is-port-reachable: 4.0.0 + serve-handler: 6.1.5 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + dev: false + /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} @@ -5127,7 +5463,6 @@ packages: /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: true /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} @@ -5140,6 +5475,15 @@ packages: semver: 7.5.4 dev: true + /sirv@2.0.3: + resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.23 + mrmime: 1.0.1 + totalist: 3.0.1 + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -5321,7 +5665,6 @@ packages: /strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - dev: true /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} @@ -5335,15 +5678,16 @@ packages: min-indent: 1.0.1 dev: true + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: false + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} dev: true - /stubborn-fs@1.2.5: - resolution: {integrity: sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==} - dev: false - /sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -5364,7 +5708,6 @@ packages: engines: {node: '>=8'} dependencies: has-flag: 4.0.0 - dev: true /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} @@ -5379,6 +5722,11 @@ packages: tslib: 2.6.2 dev: true + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + /tar@6.2.0: resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==} engines: {node: '>=10'} @@ -5447,6 +5795,10 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} dev: true /trim-newlines@4.1.1: @@ -5469,6 +5821,13 @@ packages: typescript: 5.3.2 dev: true + /ts-morph@20.0.0: + resolution: {integrity: sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg==} + dependencies: + '@ts-morph/common': 0.21.0 + code-block-writer: 12.0.0 + dev: false + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: @@ -5508,11 +5867,6 @@ packages: engines: {node: '>=12.20'} dev: false - /type-fest@3.13.1: - resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} - engines: {node: '>=14.16'} - dev: false - /typed-array-buffer@1.0.0: resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} engines: {node: '>= 0.4'} @@ -5563,11 +5917,6 @@ packages: hasBin: true dev: true - /uint8array-extras@0.3.0: - resolution: {integrity: sha512-erJsJwQ0tKdwuqI0359U8ijkFmfiTcq25JvvzRVc1VP+2son1NJRXhxcAKJmAW3ajM8JSGAfsAXye8g4s+znxA==} - engines: {node: '>=18'} - dev: false - /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: @@ -5632,6 +5981,13 @@ packages: picocolors: 1.0.0 dev: true + /update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + dev: false + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -5676,6 +6032,11 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: false + /verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -5687,13 +6048,37 @@ packages: dev: true optional: true + /vite-plugin-inspect@0.7.42(rollup@4.6.0)(vite@4.5.0): + resolution: {integrity: sha512-JCyX86wr3siQc+p9Kd0t8VkFHAJag0RaQVIpdFGSv5FEaePEVB6+V/RGtz2dQkkGSXQzRWrPs4cU3dRKg32bXw==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^3.1.0 || ^4.0.0 || ^5.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + dependencies: + '@antfu/utils': 0.7.6 + '@rollup/pluginutils': 5.0.5(rollup@4.6.0) + debug: 4.3.4 + error-stack-parser-es: 0.1.1 + fs-extra: 11.1.1 + open: 9.1.0 + picocolors: 1.0.0 + sirv: 2.0.3 + vite: 4.5.0(@types/node@20.8.6) + transitivePeerDependencies: + - rollup + - supports-color + dev: true + /vite-plugin-resolve@2.5.1: resolution: {integrity: sha512-9dD0Yq5JT1RxHQGZOyhC7e/JlhyhMCftCpQ8TPzQa7KEB/3ERnoCPinH3VJk/0C8qHsA+l41bIcHh5BcHBTmAw==} dependencies: lib-esm: 0.4.1 dev: true - /vite@4.5.0: + /vite@4.5.0(@types/node@20.8.6): resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -5721,9 +6106,10 @@ packages: terser: optional: true dependencies: + '@types/node': 20.8.6 esbuild: 0.18.20 postcss: 8.4.31 - rollup: 4.4.1 + rollup: 4.6.0 optionalDependencies: fsevents: 2.3.3 dev: true @@ -5737,10 +6123,6 @@ packages: engines: {node: '>= 8'} dev: false - /when-exit@2.1.1: - resolution: {integrity: sha512-XLipGldz/UcleuGaoQjbYuWwD+ICRnzIjlldtwTaTWr7aZz8yQW49rXk6MHQnh+KxOiWiJpM1vIyaxprOnlW4g==} - dev: false - /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: @@ -5783,6 +6165,13 @@ packages: dependencies: isexe: 3.1.1 + /widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + dev: false + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} diff --git a/readme.md b/readme.md index 69591d66..e515eb34 100644 --- a/readme.md +++ b/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 cd youtube-music pnpm install --frozen-lockfile -pnpm start +pnpm dev ``` ## Build your own plugins @@ -203,61 +203,70 @@ Using plugins, you can: 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 -// file: main.ts -export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => { - // something -}; -``` +import style from './style.css?inline'; // import style as inline -then, register the plugin in `src/index.ts`: +import { createPlugin } from '@/utils'; -```typescript -import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back'; - -// ... - -const mainPlugins = { - // ... - 'YOUR-PLUGIN-NAME': yourPlugin, -}; -``` - -- if you need to change the front, create a file with the following template: - -```typescript -// file: renderer.ts -export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => { - // This function will be called as a preload script - // So you can use front features like `document.querySelector` -}; -``` - -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': { - // ... - }, +export default 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, + menu: async ({ getConfig, setConfig }) => { + // All *Config methods are wrapped Promise + const config = await getConfig(); + return [ + { + label: 'menu', + submenu: [1, 2, 3].map((value) => ({ + label: `value ${value}`, + type: 'radio', + checked: config.value === value, + click() { + setConfig({ value }); + }, + })), + }, + ]; }, -}; + 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 @@ -265,27 +274,42 @@ export default { - injecting custom CSS: create a `style.css` file in the same folder then: ```typescript -import path from 'node:path'; -import style from './style.css'; +// index.ts +import style from './style.css?inline'; // import style as inline -// main.ts -export default (win: Electron.BrowserWindow) => { - injectCSS(win.webContents, style); -}; +import { createPlugin } from '@/utils'; + +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 -// renderer.ts -export default () => { - // Remove the login button - document.querySelector(".sign-in-link.ytmusic-nav-bar").remove(); -}; +import { createPlugin } from '@/utils'; + +const builder = createPlugin({ + 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 - example in `navigation` plugin. + example in `sponsorblock` plugin. ## Build @@ -301,6 +325,12 @@ export default () => { Builds the app for macOS, Linux, and Windows, using [electron-builder](https://github.com/electron-userland/electron-builder). +## Production Preview + +```bash +pnpm start +``` + ## Tests ```bash diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 04946e6f..f6f0e646 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,22 +1,17 @@ -import { blockers } from '../plugins/adblocker/blocker-types'; - -import { DefaultPresetList } from '../plugins/downloader/types'; - export interface WindowSizeConfig { width: number; height: number; } +export interface WindowPositionConfig { + x: number; + y: number; +} + export interface DefaultConfig { - 'window-size': { - width: number; - height: number; - } + 'window-size': WindowSizeConfig; 'window-maximized': boolean; - 'window-position': { - x: number; - y: number; - } + 'window-position': WindowPositionConfig; url: string; options: { tray: boolean; @@ -37,10 +32,11 @@ export interface DefaultConfig { startingPage: string; overrideUserAgent: boolean; themes: string[]; - } + }, + plugins: Record, } -const defaultConfig = { +const defaultConfig: DefaultConfig = { 'window-size': { width: 1100, height: 550, @@ -69,229 +65,9 @@ const defaultConfig = { proxy: '', startingPage: '', overrideUserAgent: false, - themes: [] as string[], - }, - /** 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, - local: { - previous: '', - playPause: '', - next: '', - } as Record, - }, - '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', - }, - }, - ], - }, - }, + themes: [], }, + 'plugins': {}, }; export default defaultConfig; diff --git a/src/config/dynamic-renderer.ts b/src/config/dynamic-renderer.ts deleted file mode 100644 index 1772a722..00000000 --- a/src/config/dynamic-renderer.ts +++ /dev/null @@ -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 } = {}; - -export const getActivePlugins - = async () => await window.ipcRenderer.invoke('get-active-plugins') as Promise; - -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[keyof T]; - -export class PluginConfig { - private readonly name: string; - private readonly config: ConfigType; - private readonly defaultConfig: ConfigType; - private readonly enableFront: boolean; - - private subscribers: { [key in keyof ConfigType]?: (config: ConfigType) => void } = {}; - private allSubscribers: ((config: ConfigType) => void)[] = []; - - constructor( - name: T, - options: PluginConfigOptions = { - enableFront: false, - }, - ) { - const pluginDefaultConfig = defaultConfig.plugins[name] ?? {}; - const pluginConfig = options.initialOptions || 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 = keyof ConfigType>(key: Key): ConfigType[Key] { - return this.config?.[key]; - } - - set(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - this.onChange(key); - this.save(); - } - - getAll(): ConfigType { - return { ...this.config }; - } - - setAll(options: Partial>) { - if (!options || typeof options !== 'object') { - throw new Error('Options must be an object.'); - } - - let changed = false; - for (const [key, value] of Object.entries(options) as Entries) { - if (this.config[key] !== value) { - 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, value: ValueOf>) { - this.config[key] = value; - window.mainConfig.plugins.setMenuOptions(this.name, this.config); - this.onChange(key); - } - - subscribe(valueName: keyof ConfigType, fn: (config: ConfigType) => void) { - this.subscribers[valueName] = fn; - } - - subscribeAll(fn: (config: ConfigType) => void) { - this.allSubscribers.push(fn); - } - - /** Called only from back */ - private save() { - window.mainConfig.plugins.setOptions(this.name, this.config); - } - - private onChange(valueName: keyof ConfigType, single: boolean = true) { - this.subscribers[valueName]?.(this.config[valueName] as ConfigType); - if (single) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - } - - private setupFront() { - const ignoredMethods = ['subscribe', 'subscribeAll']; - - for (const [fnName, fn] of Object.entries(this) as Entries) { - if (typeof fn !== 'function' || fn.name in ignoredMethods) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return - this[fnName] = (async (...args: any) => await 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) => 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) => { - fn(value); - }, - ); - window.ipcRenderer.send(`${this.name}-config-subscribe`, valueName); - }; - - this.subscribeAll = (fn: (config: ConfigType) => void) => { - window.ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType) => { - fn(value); - }); - window.ipcRenderer.send(`${this.name}-config-subscribe-all`); - }; - } - } -} diff --git a/src/config/dynamic.ts b/src/config/dynamic.ts deleted file mode 100644 index 12d34f19..00000000 --- a/src/config/dynamic.ts +++ /dev/null @@ -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 } = {}; - -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 = typeof defaultConfig.plugins[T]; -type ValueOf = T[keyof T]; - -export class PluginConfig { - private readonly name: string; - private readonly config: ConfigType; - private readonly defaultConfig: ConfigType; - private readonly enableFront: boolean; - - private subscribers: { [key in keyof ConfigType]?: (config: ConfigType) => void } = {}; - private allSubscribers: ((config: ConfigType) => void)[] = []; - - constructor( - name: T, - options: PluginConfigOptions = { - enableFront: false, - }, - ) { - const pluginDefaultConfig = defaultConfig.plugins[name] ?? {}; - const pluginConfig = options.initialOptions || getOptions(name) || {}; - - this.name = name; - this.enableFront = options.enableFront; - this.defaultConfig = pluginDefaultConfig; - this.config = { ...pluginDefaultConfig, ...pluginConfig }; - - if (this.enableFront) { - this.setupFront(); - } - - activePlugins[name] = this; - } - - get = keyof ConfigType>(key: Key): ConfigType[Key] { - return this.config?.[key]; - } - - set(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - this.onChange(key); - this.save(); - } - - getAll(): ConfigType { - return { ...this.config }; - } - - setAll(options: Partial>) { - if (!options || typeof options !== 'object') { - throw new Error('Options must be an object.'); - } - - let changed = false; - for (const [key, value] of Object.entries(options) as Entries) { - if (this.config[key] !== value) { - 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, value: ValueOf>) { - this.config[key] = value; - setMenuOptions(this.name, this.config); - this.onChange(key); - } - - subscribe(valueName: keyof ConfigType, fn: (config: ConfigType) => void) { - this.subscribers[valueName] = fn; - } - - subscribeAll(fn: (config: ConfigType) => void) { - this.allSubscribers.push(fn); - } - - /** Called only from back */ - private save() { - setOptions(this.name, this.config); - } - - private onChange(valueName: keyof ConfigType, single: boolean = true) { - this.subscribers[valueName]?.(this.config[valueName] as ConfigType); - if (single) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - } - - private setupFront() { - const ignoredMethods = ['subscribe', 'subscribeAll']; - - for (const [fnName, fn] of Object.entries(this) as Entries) { - if (typeof fn !== 'function' || fn.name in ignoredMethods) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return - ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args)); - } - - ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType) => { - this.subscribe(valueName, (value) => { - sendToFront(`${this.name}-config-changed-${String(valueName)}`, value); - }); - }); - - ipcMain.on(`${this.name}-config-subscribe-all`, () => { - this.subscribeAll((value) => { - sendToFront(`${this.name}-config-changed`, value); - }); - }); - } -} diff --git a/src/config/index.ts b/src/config/index.ts index a517186f..1bfe8f30 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,15 +1,20 @@ import Store from 'electron-store'; +import { deepmerge } from 'deepmerge-ts'; import defaultConfig from './defaults'; -import plugins from './plugins'; + 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) => { 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) { set(key, value); @@ -20,34 +25,65 @@ function setMenuOption(key: string, value: unknown) { // MAGIC OF TYPESCRIPT -type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]] -type Join = K extends string | number ? - P extends string | number ? - `${K}${'' extends P ? '' : '.'}${P}` - : never : never; -type Paths = [D] extends [never] ? never : T extends object ? - { [K in keyof T]-?: K extends string | number ? - `${K}` | Join> +type Prev = [ + never, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + ...0[], +]; +type Join = K extends string | number + ? P extends string | number + ? `${K}${'' extends P ? '' : '.'}${P}` : never - }[keyof T] : '' + : never; +type Paths = [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T]-?: K extends string | number + ? `${K}` | Join> + : never; + }[keyof T] + : ''; type SplitKey = K extends `${infer A}.${infer B}` ? [A, B] : [K, string]; -type PathValue = - SplitKey extends [infer A extends keyof T, infer B extends string] - ? PathValue - : T; -const get = >(key: Key) => store.get(key) as PathValue; +type PathValue = SplitKey extends [ + infer A extends keyof T, + infer B extends string, +] + ? PathValue + : T; +const get = >(key: Key) => + store.get(key) as PathValue; export default { defaultConfig, get, set, + setPartial, setMenuOption, edit: () => store.openInEditor(), - watch(cb: Parameters[1]) { - store.onDidChange('options', cb); - store.onDidChange('plugins', cb); + watch(cb: Parameters[0]) { + store.onDidAnyChange(cb); }, plugins, }; diff --git a/src/config/plugins.ts b/src/config/plugins.ts index 3092b1d2..510a0ebb 100644 --- a/src/config/plugins.ts +++ b/src/config/plugins.ts @@ -1,27 +1,18 @@ -import { deepmerge } from '@fastify/deepmerge'; +import { deepmerge } from 'deepmerge-ts'; +import { allPlugins } from 'virtual:plugins'; import store from './store'; -import defaultConfig from './defaults'; -import { restart } from '../providers/app-controls'; -import { Entries } from '../utils/type-utils'; +import { restart } from '@/providers/app-controls'; -interface Plugin { - enabled: boolean; -} +import type { PluginConfig } from '@/types/plugins'; -type DefaultPluginsConfig = typeof defaultConfig.plugins; -const deepmergeFn = deepmerge(); - -export function getEnabled() { - const plugins = deepmergeFn(defaultConfig.plugins, (store.get('plugins') as DefaultPluginsConfig)); - return (Object.entries(plugins) as Entries).filter(([, options]) => - (options as Plugin).enabled, - ); +export function getPlugins() { + return store.get('plugins') as Record; } export function isEnabled(plugin: string) { - const pluginConfig = (store.get('plugins') as Record)[plugin]; + const pluginConfig = deepmerge(allPlugins[plugin].config ?? { enabled: false }, (store.get('plugins') as Record)[plugin] ?? {}); return pluginConfig !== undefined && pluginConfig.enabled; } @@ -69,7 +60,7 @@ export function disable(plugin: string) { export default { isEnabled, - getEnabled, + getPlugins, enable, disable, setOptions, diff --git a/src/config/store.ts b/src/config/store.ts index e9271d64..49e56f0d 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,25 +1,26 @@ import Store from 'electron-store'; import Conf from 'conf'; -import is from 'electron-is'; import defaults from './defaults'; -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>, plugin: keyof typeof defaults.plugins) => { - if (!store.get(`plugins.${plugin}`)) { - store.set(`plugins.${plugin}`, defaults.plugins[plugin]); - } -}; +import { DefaultPresetList, type Preset } from '@/plugins/downloader/types'; const migrations = { + '>=3.0.0'(store: Conf>) { + const discordConfig = store.get('plugins.discord') as Record; + 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>) { const listenAlong = store.get('plugins.discord.listenAlong'); if (listenAlong !== undefined) { @@ -28,19 +29,24 @@ const migrations = { } }, '>=2.1.0'(store: Conf>) { - const originalPreset = store.get('plugins.downloader.preset') as string | undefined; + const originalPreset = store.get('plugins.downloader.preset') as + | string + | undefined; if (originalPreset) { if (originalPreset !== 'opus') { store.set('plugins.downloader.selectedPreset', 'Custom'); store.set('plugins.downloader.customPresetSetting', { 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); } else { store.set('plugins.downloader.selectedPreset', 'Source'); store.set('plugins.downloader.customPresetSetting', { extension: null, - ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [], + ffmpegArgs: + (store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [], } satisfies Preset); } store.delete('plugins.downloader.preset'); @@ -48,12 +54,11 @@ const migrations = { } }, '>=1.20.0'(store: Conf>) { - setDefaultPluginOptions(store, 'visualizer'); + store.delete('plugins.visualizer'); // default value is now in the plugin if (store.get('plugins.notifications.toastStyle') === undefined) { const pluginOptions = store.get('plugins.notifications') || {}; store.set('plugins.notifications', { - ...defaults.plugins.notifications, ...pluginOptions, }); } @@ -64,7 +69,7 @@ const migrations = { } }, '>=1.17.0'(store: Conf>) { - 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) { store.set('plugins.video-toggle.mode', 'custom'); @@ -88,31 +93,36 @@ const migrations = { } }, '>=1.12.0'(store: Conf>) { - const options = store.get('plugins.shortcuts') as Record>; - let updated = false; - for (const optionType of ['global', 'local']) { - if (Array.isArray(options[optionType])) { - const optionsArray = options[optionType] as { + const options = store.get('plugins.shortcuts') as Record< + string, + | { action: string; shortcut: unknown; - }[]; - const updatedOptions: Record = {}; - for (const optionObject of optionsArray) { - if (optionObject.action && optionObject.shortcut) { - updatedOptions[optionObject.action] = optionObject.shortcut; + }[] + | Record + > | undefined; + if (options) { + 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 = {}; + 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>) { @@ -155,7 +165,10 @@ const migrations = { }; 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, migrations, }); diff --git a/src/index.ts b/src/index.ts index c8bffd75..414c20f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,30 +2,55 @@ import path from 'node:path'; import url from 'node:url'; import fs from 'node:fs'; -import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron'; -import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request'; +import { + 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 unhandled from 'electron-unhandled'; import { autoUpdater } from 'electron-updater'; import electronDebug from 'electron-debug'; 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 { 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 config from '@/config'; -// eslint-disable-next-line import/order -import { mainPlugins } from 'virtual:MainPlugins'; +import { refreshMenu, setApplicationMenu } from '@/menu'; +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 unhandled({ @@ -47,7 +72,10 @@ if (!gotTheLock) { // SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt) // 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 (is.dev()) { console.log('Disabling hardware acceleration'); @@ -83,20 +111,106 @@ function onClosed() { 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)); -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; + 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); // Load user CSS const themes: string[] = config.get('options.themes'); @@ -108,7 +222,10 @@ async function loadPlugins(win: BrowserWindow) { 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', () => { if (is.dev()) { - console.log('did finish load'); + console.log(LoggerPrefix, 'did finish load'); 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() { @@ -160,21 +263,24 @@ async function createMainWindow() { ...(isTesting() ? undefined : { - // Sandbox is only enabled in tests for now - // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts - sandbox: false, - }), + // Sandbox is only enabled in tests for now + // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts + sandbox: false, + }), }, frame: !is.macOS() && !useInlineMenu, titleBarOverlay: defaultTitleBarOverlayOptions, titleBarStyle: useInlineMenu ? 'hidden' - : (is.macOS() - ? 'hiddenInset' - : 'default'), + : is.macOS() + ? 'hiddenInset' + : 'default', autoHideMenuBar: config.get('options.hideMenu'), }); - await loadPlugins(win); + initHook(win); + initTheme(win); + + await loadAllMainPlugins(win); if (windowPosition) { @@ -198,7 +304,11 @@ async function createMainWindow() { // Window is offscreen if (is.dev()) { 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 { @@ -221,39 +331,22 @@ async function createMainWindow() { : config.defaultConfig.url; 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', () => { if (win.isMaximized()) { return; } - const position = win.getPosition(); - const isPiPEnabled: boolean - = config.plugins.isEnabled('picture-in-picture') - && config.plugins.getOptions('picture-in-picture').isInPiP; - if (!isPiPEnabled) { - - lateSave('window-position', { x: position[0], y: position[1] }); - } else if (config.plugins.getOptions('picture-in-picture').savePosition) { - lateSave('pip-position', position, setPiPOptions); - } + const [x, y] = win.getPosition(); + lateSave('window-position', { x, y }); }); let winWasMaximized: boolean; win.on('resize', () => { - const windowSize = win.getSize(); + const [width, height] = win.getSize(); const isMaximized = win.isMaximized(); - const isPiPEnabled - = config.plugins.isEnabled('picture-in-picture') - && config.plugins.getOptions('picture-in-picture').isInPiP; - - if (!isPiPEnabled && winWasMaximized !== isMaximized) { + if (winWasMaximized !== isMaximized) { winWasMaximized = isMaximized; config.set('window-maximized', isMaximized); } @@ -262,19 +355,19 @@ async function createMainWindow() { return; } - if (!isPiPEnabled) { - lateSave('window-size', { - width: windowSize[0], - height: windowSize[1], - }); - } else if (config.plugins.getOptions('picture-in-picture').saveSize) { - lateSave('pip-size', windowSize, setPiPOptions); - } + lateSave('window-size', { + width, + height, + }); }); const savedTimeouts: Record = {}; - 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]) { clearTimeout(savedTimeouts[key]); } @@ -285,7 +378,7 @@ async function createMainWindow() { }, 600); } - app.on('render-process-gone', (event, webContents, details) => { + app.on('render-process-gone', (_event, _webContents, details) => { showUnresponsiveDialog(win, details); }); @@ -301,7 +394,10 @@ async function createMainWindow() { if (useInlineMenu) { win.setTitleBarOverlay({ ...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 { 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 scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!); + const scriptPath = path.join( + rendererPath, + scriptSrc.getAttribute('src')!, + ); const scriptString = fs.readFileSync(scriptPath, 'utf-8'); - await win.webContents.executeJavaScriptInIsolatedWorld(0, [{ - code: scriptString + ';0', - url: url.pathToFileURL(scriptPath).toString(), - }], true); + await win.webContents.executeJavaScriptInIsolatedWorld( + 0, + [ + { + code: scriptString + ';0', + url: url.pathToFileURL(scriptPath).toString(), + }, + ], + true, + ); } }); @@ -339,27 +446,32 @@ async function createMainWindow() { return win; } -app.once('browser-window-created', (event, win) => { +app.once('browser-window-created', (_event, win) => { if (config.get('options.overrideUserAgent')) { // User agents are from https://developers.whatismybrowser.com/useragents/explore/ const originalUserAgent = win.webContents.userAgent; const userAgents = { 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', }; - const updatedUserAgent - = is.macOS() ? userAgents.mac - : (is.windows() ? userAgents.windows - : userAgents.linux); + const updatedUserAgent = is.macOS() + ? userAgents.mac + : is.windows() + ? userAgents.windows + : userAgents.linux; win.webContents.userAgent = updatedUserAgent; app.userAgentFallback = updatedUserAgent; win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { // 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; } @@ -370,33 +482,41 @@ app.once('browser-window-created', (event, win) => { setupSongInfo(win); setupAppControls(); - win.webContents.on('did-fail-load', ( - _event, - errorCode, - errorDescription, - validatedURL, - isMainFrame, - frameProcessId, - frameRoutingId, - ) => { - const log = JSON.stringify({ - error: 'did-fail-load', + win.webContents.on( + 'did-fail-load', + ( + _event, errorCode, errorDescription, validatedURL, isMainFrame, frameProcessId, frameRoutingId, - }, null, '\t'); - if (is.dev()) { - console.log(log); - } + ) => { + const log = JSON.stringify( + { + 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 - win.webContents.send('log', log); - win.webContents.loadFile(path.join(__dirname, 'error.html')); - } - }); + if (errorCode !== -3) { + // -3 is a false positive + win.webContents.send('log', log); + win.webContents.loadFile(path.join(__dirname, 'error.html')); + } + }, + ); win.webContents.on('will-prevent-unload', (event) => { event.preventDefault(); @@ -422,7 +542,7 @@ app.on('activate', async () => { } }); -app.on('ready', async () => { +app.whenReady().then(async () => { if (config.get('options.autoResetAppCache')) { // Clear cache after 20s const clearCacheTimeout = setTimeout(() => { @@ -442,17 +562,29 @@ app.on('ready', async () => { const appLocation = process.execPath; const appData = app.getPath('appData'); // Check shortcut validity if not in dev mode / running portable app - if (!is.dev() && !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 + if ( + !is.dev() && + !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 if ( - shortcutDetails.target !== appLocation - || shortcutDetails.appUserModelId !== appID + shortcutDetails.target !== appLocation || + shortcutDetails.appUserModelId !== appID ) { throw 'needUpdate'; } - } catch (error) { // If not valid -> Register shortcut + } catch (error) { + // If not valid -> Register shortcut shell.writeShortcutLink( shortcutPath, error === 'needUpdate' ? 'update' : 'create', @@ -468,8 +600,8 @@ app.on('ready', async () => { } mainWindow = await createMainWindow(); - setApplicationMenu(mainWindow); - refreshMenu(mainWindow); + await setApplicationMenu(mainWindow); + await refreshMenu(mainWindow); setUpTray(app, mainWindow); setupProtocolHandler(mainWindow); @@ -514,8 +646,8 @@ app.on('ready', async () => { clearTimeout(updateTimeout); }, 2000); autoUpdater.on('update-available', () => { - const downloadLink - = 'https://github.com/th-ch/youtube-music/releases/latest'; + const downloadLink = + 'https://github.com/th-ch/youtube-music/releases/latest'; const dialogOptions: Electron.MessageBoxOptions = { type: 'info', buttons: ['OK', 'Download', 'Disable updates'], @@ -555,8 +687,10 @@ app.on('ready', async () => { if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) { dialog.showMessageBox(mainWindow, { - type: 'info', title: 'Hide Menu Enabled', - message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", + type: 'info', + 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); } @@ -582,31 +716,36 @@ app.on('ready', async () => { } }); -function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) { +function showUnresponsiveDialog( + win: BrowserWindow, + details: Electron.RenderProcessGoneDetails, +) { if (details) { console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t')); } - dialog.showMessageBox(win, { - type: 'error', - title: 'Window Unresponsive', - message: 'The Application is Unresponsive', - detail: 'We are sorry for the inconvenience! please choose what to do:', - buttons: ['Wait', 'Relaunch', 'Quit'], - cancelId: 0, - }).then((result) => { - switch (result.response) { - case 1: { - restart(); - break; - } + dialog + .showMessageBox(win, { + type: 'error', + title: 'Window Unresponsive', + message: 'The Application is Unresponsive', + detail: 'We are sorry for the inconvenience! please choose what to do:', + buttons: ['Wait', 'Relaunch', 'Quit'], + cancelId: 0, + }) + .then((result) => { + switch (result.response) { + case 1: { + restart(); + break; + } - case 2: { - app.quit(); - break; + case 2: { + app.quit(); + break; + } } - } - }); + }); } function removeContentSecurityPolicy( @@ -629,18 +768,21 @@ function removeContentSecurityPolicy( }); // When multiple listeners are defined, apply them all - betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => { - return listeners.reduce( - async (accumulator, listener) => { - const acc = await accumulator; - if (acc.cancel) { - return acc; - } + betterSession.webRequest.setResolver( + 'onHeadersReceived', + async (listeners) => { + return listeners.reduce( + async (accumulator, listener) => { + const acc = await accumulator; + if (acc.cancel) { + return acc; + } - const result = await listener.apply(); - return { ...accumulator, ...result }; - }, - Promise.resolve({ cancel: false }), - ); - }); + const result = await listener.apply(); + return { ...accumulator, ...result }; + }, + Promise.resolve({ cancel: false }), + ); + }, + ); } diff --git a/src/loader/main.ts b/src/loader/main.ts new file mode 100644 index 00000000..52c6cbb1 --- /dev/null +++ b/src/loader/main.ts @@ -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> = {}; + +const createContext = (id: string, win: BrowserWindow): BackendContext => ({ + 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 => { + 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 => { + 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[] = []; + + 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 | undefined => { + return loadedPluginMap[id]; +}; + +export const getAllLoadedMainPlugins = () => { + return loadedPluginMap; +}; diff --git a/src/loader/menu.ts b/src/loader/menu.ts new file mode 100644 index 00000000..f44d886e --- /dev/null +++ b/src/loader/menu.ts @@ -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 = {}; +const createContext = (id: string, win: BrowserWindow): MenuContext => ({ + 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; +}; diff --git a/src/loader/preload.ts b/src/loader/preload.ts new file mode 100644 index 00000000..74eb9bf7 --- /dev/null +++ b/src/loader/preload.ts @@ -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> = {}; +const createContext = (id: string): PreloadContext => ({ + 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 | undefined => { + return loadedPluginMap[id]; +}; + +export const getAllLoadedPreloadPlugins = () => { + return loadedPluginMap; +}; diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts new file mode 100644 index 00000000..7c5bafef --- /dev/null +++ b/src/loader/renderer.ts @@ -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 void)[]> = {}; +const loadedPluginMap: Record> = {}; + +export const createContext = (id: string): RendererContext => ({ + 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 | undefined => { + return loadedPluginMap[id]; +}; + +export const getAllLoadedRendererPlugins = () => { + return loadedPluginMap; +}; diff --git a/src/menu.ts b/src/menu.ts index f302c12d..1fa0506a 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,25 +1,35 @@ 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 { restart } from './providers/app-controls'; +import { allPlugins } from 'virtual:plugins'; + import config from './config'; + +import { restart } from './providers/app-controls'; import { startingPages } from './providers/extracted-data'; import promptOptions from './providers/prompt-options'; -// eslint-disable-next-line import/order -import { menuPlugins as menuList } from 'virtual:MenuPlugins'; - -import { getAvailablePluginNames } from './plugins/utils/main'; +import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; // True only if in-app-menu was loaded on launch const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); -const betaPlugins = ['crossfade', 'lumiastream']; - -const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({ +const pluginEnabledMenu = ( + plugin: string, + label = '', + hasSubmenu = false, + refreshMenu: (() => void) | undefined = undefined, +): Electron.MenuItemConstructorOptions => ({ label: label || plugin, type: 'checkbox', checked: config.plugins.isEnabled(plugin), @@ -36,45 +46,64 @@ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refre }, }); -export const refreshMenu = (win: BrowserWindow) => { - setApplicationMenu(win); +export const refreshMenu = async (win: BrowserWindow) => { + await setApplicationMenu(win); if (inAppMenuActive) { win.webContents.send('refresh-in-app-menu'); } }; -export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { +export const mainMenuTemplate = async (win: BrowserWindow): Promise => { 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 [ { label: 'Plugins', - submenu: - 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); - }), + submenu: pluginMenus, }, { label: 'Options', @@ -83,7 +112,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Auto-update', type: 'checkbox', checked: config.get('options.autoUpdates'), - click(item) { + click(item: MenuItem) { config.setMenuOption('options.autoUpdates', item.checked); }, }, @@ -91,21 +120,22 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Resume last song when app starts', type: 'checkbox', checked: config.get('options.resumeOnStart'), - click(item) { + click(item: MenuItem) { config.setMenuOption('options.resumeOnStart', item.checked); }, }, { label: 'Starting page', submenu: (() => { - const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({ - label: name, - type: 'radio', - checked: config.get('options.startingPage') === name, - click() { - config.set('options.startingPage', name); - }, - })); + const subMenuArray: Electron.MenuItemConstructorOptions[] = + Object.keys(startingPages).map((name) => ({ + label: name, + type: 'radio', + checked: config.get('options.startingPage') === name, + click() { + config.set('options.startingPage', name); + }, + })); subMenuArray.unshift({ label: 'Unset', type: 'radio', @@ -124,8 +154,11 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Remove upgrade button', type: 'checkbox', checked: config.get('options.removeUpgradeButton'), - click(item) { - config.setMenuOption('options.removeUpgradeButton', item.checked); + click(item: MenuItem) { + config.setMenuOption( + 'options.removeUpgradeButton', + item.checked, + ); }, }, { @@ -190,7 +223,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Single instance lock', type: 'checkbox', checked: true, - click(item) { + click(item: MenuItem) { if (!item.checked && app.hasSingleInstanceLock()) { app.releaseSingleInstanceLock(); } else if (item.checked && !app.hasSingleInstanceLock()) { @@ -202,43 +235,45 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Always on top', type: 'checkbox', checked: config.get('options.alwaysOnTop'), - click(item) { + click(item: MenuItem) { config.setMenuOption('options.alwaysOnTop', item.checked); win.setAlwaysOnTop(item.checked); }, }, - ...(is.windows() || is.linux() + ...((is.windows() || is.linux() ? [ - { - label: 'Hide menu', - type: 'checkbox', - checked: config.get('options.hideMenu'), - click(item) { - config.setMenuOption('options.hideMenu', item.checked); - if (item.checked && !config.get('options.hideMenuWarned')) { - dialog.showMessageBox(win, { - type: 'info', title: 'Hide Menu Enabled', - message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)', - }); - } + { + label: 'Hide menu', + type: 'checkbox', + checked: config.get('options.hideMenu'), + click(item) { + config.setMenuOption('options.hideMenu', item.checked); + if (item.checked && !config.get('options.hideMenuWarned')) { + dialog.showMessageBox(win, { + type: 'info', + 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[], - ...(is.windows() || is.macOS() + ] + : []) satisfies Electron.MenuItemConstructorOptions[]), + ...((is.windows() || is.macOS() ? // Only works on Win/Mac - // https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows - [ - { - label: 'Start at login', - type: 'checkbox', - checked: config.get('options.startAtLogin'), - click(item) { - config.setMenuOption('options.startAtLogin', item.checked); + // https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows + [ + { + label: 'Start at login', + type: 'checkbox', + checked: config.get('options.startAtLogin'), + click(item) { + config.setMenuOption('options.startAtLogin', item.checked); + }, }, - }, - ] - : []) satisfies Electron.MenuItemConstructorOptions[], + ] + : []) satisfies Electron.MenuItemConstructorOptions[]), { label: 'Tray', submenu: [ @@ -254,7 +289,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { { label: 'Enabled + app visible', type: 'radio', - checked: config.get('options.tray') && config.get('options.appVisible'), + checked: + config.get('options.tray') && config.get('options.appVisible'), click() { config.setMenuOption('options.tray', true); config.setMenuOption('options.appVisible', true); @@ -263,7 +299,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { { label: 'Enabled + app hidden', type: 'radio', - checked: config.get('options.tray') && !config.get('options.appVisible'), + checked: + config.get('options.tray') && !config.get('options.appVisible'), click() { config.setMenuOption('options.tray', true); config.setMenuOption('options.appVisible', false); @@ -274,8 +311,11 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Play/Pause on click', type: 'checkbox', checked: config.get('options.trayClickPlayPause'), - click(item) { - config.setMenuOption('options.trayClickPlayPause', item.checked); + click(item: MenuItem) { + config.setMenuOption( + 'options.trayClickPlayPause', + item.checked, + ); }, }, ], @@ -287,7 +327,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { { label: 'Set Proxy', type: 'normal', - async click(item) { + async click(item: MenuItem) { await setProxy(item, win); }, }, @@ -295,7 +335,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Override useragent', type: 'checkbox', checked: config.get('options.overrideUserAgent'), - click(item) { + click(item: MenuItem) { config.setMenuOption('options.overrideUserAgent', item.checked); }, }, @@ -303,40 +343,46 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { label: 'Disable hardware acceleration', type: 'checkbox', checked: config.get('options.disableHardwareAcceleration'), - click(item) { - config.setMenuOption('options.disableHardwareAcceleration', item.checked); + click(item: MenuItem) { + config.setMenuOption( + 'options.disableHardwareAcceleration', + item.checked, + ); }, }, { label: 'Restart on config changes', type: 'checkbox', checked: config.get('options.restartOnConfigChanges'), - click(item) { - config.setMenuOption('options.restartOnConfigChanges', item.checked); + click(item: MenuItem) { + config.setMenuOption( + 'options.restartOnConfigChanges', + item.checked, + ); }, }, { label: 'Reset App cache when app starts', type: 'checkbox', checked: config.get('options.autoResetAppCache'), - click(item) { + click(item: MenuItem) { config.setMenuOption('options.autoResetAppCache', item.checked); }, }, { type: 'separator' }, is.macOS() ? { - label: 'Toggle DevTools', - // Cannot use "toggleDevTools" role in macOS - click() { - const { webContents } = win; - if (webContents.isDevToolsOpened()) { - webContents.closeDevTools(); - } else { - webContents.openDevTools(); - } - }, - } + label: 'Toggle DevTools', + // Cannot use "toggleDevTools" role in macOS + click() { + const { webContents } = win; + if (webContents.isDevToolsOpened()) { + webContents.closeDevTools(); + } else { + webContents.openDevTools(); + } + }, + } : { role: 'toggleDevTools' }, { label: 'Edit config.json', @@ -354,8 +400,14 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { { role: 'reload' }, { role: 'forceReload' }, { 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' }, { type: 'separator' }, { role: 'togglefullscreen' }, @@ -396,14 +448,12 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { }, { label: 'About', - submenu: [ - { role: 'about' }, - ], - } + submenu: [{ role: 'about' }], + }, ]; }; -export const setApplicationMenu = (win: Electron.BrowserWindow) => { - const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)]; +export const setApplicationMenu = async (win: Electron.BrowserWindow) => { + const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)]; if (process.platform === 'darwin') { const { name } = app; menuTemplate.unshift({ @@ -432,23 +482,27 @@ export const setApplicationMenu = (win: Electron.BrowserWindow) => { }; async function setProxy(item: Electron.MenuItem, win: BrowserWindow) { - const output = await prompt({ - title: 'Set Proxy', - label: 'Enter Proxy Address: (leave empty to disable)', - value: config.get('options.proxy'), - type: 'input', - inputAttrs: { - type: 'url', - placeholder: "Example: 'socks5://127.0.0.1:9999", + const output = await prompt( + { + title: 'Set Proxy', + label: 'Enter Proxy Address: (leave empty to disable)', + value: config.get('options.proxy'), + type: 'input', + inputAttrs: { + type: 'url', + placeholder: "Example: 'socks5://127.0.0.1:9999", + }, + width: 450, + ...promptOptions(), }, - width: 450, - ...promptOptions(), - }, win); + win, + ); if (typeof output === 'string') { config.setMenuOption('options.proxy', output); item.checked = output !== ''; - } else { // User pressed cancel + } else { + // User pressed cancel item.checked = !item.checked; // Reset checkbox } } diff --git a/src/plugins/adblocker/blocker.ts b/src/plugins/adblocker/blocker.ts index 82d2986f..2deddf3f 100644 --- a/src/plugins/adblocker/blocker.ts +++ b/src/plugins/adblocker/blocker.ts @@ -17,10 +17,12 @@ const SOURCES = [ 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', ]; +let blocker: ElectronBlocker | undefined; + export const loadAdBlockerEngine = async ( session: Electron.Session | undefined = undefined, - cache = true, - additionalBlockLists = [], + cache: boolean = true, + additionalBlockLists: string[] = [], disableDefaultLists: boolean | unknown[] = false, ) => { // Only use cache if no additional blocklists are passed @@ -45,7 +47,7 @@ export const loadAdBlockerEngine = async ( ]; try { - const blocker = await ElectronBlocker.fromLists( + blocker = await ElectronBlocker.fromLists( (url: string) => net.fetch(url), 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); diff --git a/src/plugins/adblocker/config.ts b/src/plugins/adblocker/config.ts deleted file mode 100644 index 0ff54eb3..00000000 --- a/src/plugins/adblocker/config.ts +++ /dev/null @@ -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, -}); diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts new file mode 100644 index 00000000..44e8cb65 --- /dev/null +++ b/src/plugins/adblocker/index.ts @@ -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(); + } + } + }, + } +}); diff --git a/src/plugins/adblocker/inject.d.ts b/src/plugins/adblocker/inject.d.ts deleted file mode 100644 index 435d23f7..00000000 --- a/src/plugins/adblocker/inject.d.ts +++ /dev/null @@ -1 +0,0 @@ -export const inject: () => void; diff --git a/src/plugins/adblocker/inject-cliqz-preload.ts b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts similarity index 100% rename from src/plugins/adblocker/inject-cliqz-preload.ts rename to src/plugins/adblocker/injectors/inject-cliqz-preload.ts diff --git a/src/plugins/adblocker/injectors/inject.d.ts b/src/plugins/adblocker/injectors/inject.d.ts new file mode 100644 index 00000000..8078bbf0 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.d.ts @@ -0,0 +1,3 @@ +export const inject: () => void; + +export const isInjected: () => boolean; diff --git a/src/plugins/adblocker/inject.js b/src/plugins/adblocker/injectors/inject.js similarity index 99% rename from src/plugins/adblocker/inject.js rename to src/plugins/adblocker/injectors/inject.js index edc8b03b..12ded09a 100644 --- a/src/plugins/adblocker/inject.js +++ b/src/plugins/adblocker/injectors/inject.js @@ -7,7 +7,13 @@ Parts of this code is derived from set-constant.js: https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 */ + +let injected = false; + +export const isInjected = () => isInjected; + export const inject = () => { + injected = true; { const pruner = function (o) { delete o.playerAds; diff --git a/src/plugins/adblocker/main.ts b/src/plugins/adblocker/main.ts deleted file mode 100644 index ef05889c..00000000 --- a/src/plugins/adblocker/main.ts +++ /dev/null @@ -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, - ); - } -}; diff --git a/src/plugins/adblocker/menu.ts b/src/plugins/adblocker/menu.ts deleted file mode 100644 index f2eb1182..00000000 --- a/src/plugins/adblocker/menu.ts +++ /dev/null @@ -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); - }, - })), - }, - ]; -}; diff --git a/src/plugins/adblocker/preload.ts b/src/plugins/adblocker/preload.ts deleted file mode 100644 index 57ab0247..00000000 --- a/src/plugins/adblocker/preload.ts +++ /dev/null @@ -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(); - } -}; diff --git a/src/plugins/adblocker/blocker-types.ts b/src/plugins/adblocker/types/index.ts similarity index 100% rename from src/plugins/adblocker/blocker-types.ts rename to src/plugins/adblocker/types/index.ts diff --git a/src/plugins/album-color-theme/index.ts b/src/plugins/album-color-theme/index.ts new file mode 100644 index 00000000..be8d061c --- /dev/null +++ b/src/plugins/album-color-theme/index.ts @@ -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('#player-page'); + this.navBarBackground = document.querySelector('#nav-bar-background'); + this.ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); + this.playerBarBackground = document.querySelector('#player-bar-background'); + this.sidebarBig = document.querySelector('#guide-wrapper'); + this.sidebarSmall = document.querySelector('#mini-guide-background'); + this.ytmusicAppLayout = document.querySelector('#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) => { + 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('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)); + } + } + }); + }, + } +}); diff --git a/src/plugins/album-color-theme/main.ts b/src/plugins/album-color-theme/main.ts deleted file mode 100644 index 1d313bf0..00000000 --- a/src/plugins/album-color-theme/main.ts +++ /dev/null @@ -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); -}; diff --git a/src/plugins/album-color-theme/renderer.ts b/src/plugins/album-color-theme/renderer.ts deleted file mode 100644 index a07d08fd..00000000 --- a/src/plugins/album-color-theme/renderer.ts +++ /dev/null @@ -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('#player-page'); - const navBarBackground = document.querySelector('#nav-bar-background'); - const ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); - const playerBarBackground = document.querySelector('#player-bar-background'); - const sidebarBig = document.querySelector('#guide-wrapper'); - const sidebarSmall = document.querySelector('#mini-guide-background'); - const ytmusicAppLayout = document.querySelector('#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('tp-yt-paper-listbox'); - changeElementColor(ytRightClickList, hue, saturation, lightness - 15); - } else { - if (playerPage) { - playerPage.style.backgroundColor = '#000000'; - } - } - }) - .catch((e) => console.error(e)); - } - } - }); - }); -}; diff --git a/src/plugins/ambient-mode/config.ts b/src/plugins/ambient-mode/config.ts deleted file mode 100644 index 6ec7cc95..00000000 --- a/src/plugins/ambient-mode/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('ambient-mode'); -export default config; diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts new file mode 100644 index 00000000..e00c23b4 --- /dev/null +++ b/src/plugins/ambient-mode/index.ts @@ -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('#song-video'); + const video = document.querySelector('#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('#player-page'); + const ytmusicAppLayout = document.querySelector('#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?.(); + } + } +}); diff --git a/src/plugins/ambient-mode/main.ts b/src/plugins/ambient-mode/main.ts deleted file mode 100644 index ab1bb380..00000000 --- a/src/plugins/ambient-mode/main.ts +++ /dev/null @@ -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); -}; diff --git a/src/plugins/ambient-mode/menu.ts b/src/plugins/ambient-mode/menu.ts deleted file mode 100644 index 18a4e8da..00000000 --- a/src/plugins/ambient-mode/menu.ts +++ /dev/null @@ -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); - }, - }, -]; diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts deleted file mode 100644 index 227686fe..00000000 --- a/src/plugins/ambient-mode/renderer.ts +++ /dev/null @@ -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('#song-video'); - const video = document.querySelector('#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('#player-page'); - const ytmusicAppLayout = document.querySelector('#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 }); - } -}; diff --git a/src/plugins/audio-compressor.ts b/src/plugins/audio-compressor.ts new file mode 100644 index 00000000..ea3d7002 --- /dev/null +++ b/src/plugins/audio-compressor.ts @@ -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 }, + ); + }, +}); diff --git a/src/plugins/audio-compressor/renderer.ts b/src/plugins/audio-compressor/renderer.ts deleted file mode 100644 index 66fd94dd..00000000 --- a/src/plugins/audio-compressor/renderer.ts +++ /dev/null @@ -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, - }); diff --git a/src/plugins/blur-nav-bar/index.ts b/src/plugins/blur-nav-bar/index.ts new file mode 100644 index 00000000..8724e4b5 --- /dev/null +++ b/src/plugins/blur-nav-bar/index.ts @@ -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() {}, +}); diff --git a/src/plugins/blur-nav-bar/main.ts b/src/plugins/blur-nav-bar/main.ts deleted file mode 100644 index 1d313bf0..00000000 --- a/src/plugins/blur-nav-bar/main.ts +++ /dev/null @@ -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); -}; diff --git a/src/plugins/bypass-age-restrictions/index.ts b/src/plugins/bypass-age-restrictions/index.ts new file mode 100644 index 00000000..7d5e1bae --- /dev/null +++ b/src/plugins/bypass-age-restrictions/index.ts @@ -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'), +}); diff --git a/src/plugins/bypass-age-restrictions/renderer.ts b/src/plugins/bypass-age-restrictions/renderer.ts deleted file mode 100644 index 42df78ea..00000000 --- a/src/plugins/bypass-age-restrictions/renderer.ts +++ /dev/null @@ -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'); -}; diff --git a/src/plugins/captions-selector/back.ts b/src/plugins/captions-selector/back.ts new file mode 100644 index 00000000..573e77c2 --- /dev/null +++ b/src/plugins/captions-selector/back.ts @@ -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, 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'); + }, +}); diff --git a/src/plugins/captions-selector/config-renderer.ts b/src/plugins/captions-selector/config-renderer.ts deleted file mode 100644 index 867ab9dc..00000000 --- a/src/plugins/captions-selector/config-renderer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic-renderer'; - -const configRenderer = new PluginConfig('captions-selector', { enableFront: true }); -export default configRenderer; diff --git a/src/plugins/captions-selector/config.ts b/src/plugins/captions-selector/config.ts deleted file mode 100644 index f7878eb9..00000000 --- a/src/plugins/captions-selector/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('captions-selector', { enableFront: true }); -export default config; diff --git a/src/plugins/captions-selector/index.ts b/src/plugins/captions-selector/index.ts new file mode 100644 index 00000000..7c590571 --- /dev/null +++ b/src/plugins/captions-selector/index.ts @@ -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) => 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, +}); diff --git a/src/plugins/captions-selector/main.ts b/src/plugins/captions-selector/main.ts deleted file mode 100644 index 8073ddde..00000000 --- a/src/plugins/captions-selector/main.ts +++ /dev/null @@ -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, currentIndex: string) => await prompt( - { - title: 'Choose Caption', - label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, - type: 'select', - value: currentIndex, - selectOptions: captionLabels, - resizable: true, - ...promptOptions(), - }, - win, - )); -}; diff --git a/src/plugins/captions-selector/menu.ts b/src/plugins/captions-selector/menu.ts deleted file mode 100644 index 65b34008..00000000 --- a/src/plugins/captions-selector/menu.ts +++ /dev/null @@ -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); - }, - }, -]; diff --git a/src/plugins/captions-selector/renderer.ts b/src/plugins/captions-selector/renderer.ts index cd4c0833..cce97f6e 100644 --- a/src/plugins/captions-selector/renderer.ts +++ b/src/plugins/captions-selector/renderer.ts @@ -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 { ElementFromHtml } from '../utils/renderer'; -import { YoutubePlayer } from '../../types/youtube-player'; +import { YoutubePlayer } from '@/types/youtube-player'; -import type { ConfigType } from '../../config/dynamic'; - -interface LanguageOptions { +export interface LanguageOptions { displayName: string; id: string | null; is_default: boolean; @@ -20,76 +18,134 @@ interface LanguageOptions { vss_id: string; } -let captionsSelectorConfig: ConfigType<'captions-selector'>; +export interface CaptionsSelectorConfig { + enabled: boolean; + disableCaptions: boolean; + autoload: boolean; + lastCaptionsCode: string; +} -const $ = (selector: string): Element => document.querySelector(selector)!; - -const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML); - -export default () => { - captionsSelectorConfig = configProvider.getAll(); - - configProvider.subscribeAll((newConfig) => { - captionsSelectorConfig = newConfig; - }); - document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true }); -}; - -function setup(api: YoutubePlayer) { - $('.right-controls-buttons').append(captionsSettingsButton); - - let captionTrackList = api.getOption('captions', 'tracklist') ?? []; - - $('video').addEventListener('srcChanged', () => { - if (captionsSelectorConfig.disableCaptions) { - setTimeout(() => api.unloadModule('captions'), 100); - captionsSettingsButton.style.display = 'none'; - 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('captions', 'track')!; +export default createRenderer< + { + captionsSettingsButton: HTMLElement; + captionTrackList: LanguageOptions[] | null; + api: YoutubePlayer | null; + config: CaptionsSelectorConfig | null; + setConfig: (config: Partial) => void; + videoChangeListener: () => void; + captionsButtonClickListener: () => void; + }, + CaptionsSelectorConfig +>({ + captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML), + captionTrackList: null, + api: null, + config: null, + setConfig: () => {}, + async captionsButtonClickListener() { + if (this.captionTrackList?.length) { + const currentCaptionTrack = this.api!.getOption( + 'captions', + 'track', + ); let currentIndex = currentCaptionTrack - ? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!) + ? this.captionTrackList.indexOf( + this.captionTrackList.find( + (track) => + track.languageCode === currentCaptionTrack.languageCode, + )!, + ) : null; const captionLabels = [ - ...captionTrackList.map((track) => track.displayName), + ...this.captionTrackList.map((track) => track.displayName), 'None', ]; - currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number; + currentIndex = (await window.ipcRenderer.invoke( + 'captionsSelector', + captionLabels, + currentIndex, + )) as number; if (currentIndex === null) { return; } - const newCaptions = captionTrackList[currentIndex]; - configProvider.set('lastCaptionsCode', newCaptions?.languageCode); + const newCaptions = this.captionTrackList[currentIndex]; + this.setConfig({ lastCaptionsCode: newCaptions?.languageCode }); if (newCaptions) { - api.setOption('captions', 'track', { languageCode: newCaptions.languageCode }); + this.api?.setOption('captions', 'track', { + languageCode: newCaptions.languageCode, + }); } 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('#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('captions', 'tracklist') ?? []; + + document + .querySelector('video') + ?.addEventListener('srcChanged', this.videoChangeListener); + this.captionsSettingsButton.addEventListener( + 'click', + this.captionsButtonClickListener, + ); + }, + onConfigChange(newConfig) { + this.config = newConfig; + }, +}); diff --git a/src/plugins/compact-sidebar/index.ts b/src/plugins/compact-sidebar/index.ts new file mode 100644 index 00000000..7119ab0b --- /dev/null +++ b/src/plugins/compact-sidebar/index.ts @@ -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('#button')?.click(); + } + }, + stop() { + if (this.isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + }, + onConfigChange() { + if (this.isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + } + }, +}); diff --git a/src/plugins/compact-sidebar/renderer.ts b/src/plugins/compact-sidebar/renderer.ts deleted file mode 100644 index ad2aab97..00000000 --- a/src/plugins/compact-sidebar/renderer.ts +++ /dev/null @@ -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('#button')?.click(); - } -}; diff --git a/src/plugins/crossfade/config-renderer.ts b/src/plugins/crossfade/config-renderer.ts deleted file mode 100644 index d9c1af27..00000000 --- a/src/plugins/crossfade/config-renderer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic-renderer'; - -const config = new PluginConfig('crossfade', { enableFront: true }); -export default config; diff --git a/src/plugins/crossfade/config.ts b/src/plugins/crossfade/config.ts deleted file mode 100644 index ffe2232d..00000000 --- a/src/plugins/crossfade/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('crossfade', { enableFront: true }); -export default config; diff --git a/src/plugins/crossfade/fader.ts b/src/plugins/crossfade/fader.ts index c9442ba0..0066f8c2 100644 --- a/src/plugins/crossfade/fader.ts +++ b/src/plugins/crossfade/fader.ts @@ -15,8 +15,6 @@ * v0.2.0, 07/2016 */ -'use strict'; - // Internal utility: check if value is a valid volume level and throw if not const validateVolumeLevel = (value: number) => { // Number between 0 and 1? diff --git a/src/plugins/crossfade/index.ts b/src/plugins/crossfade/index.ts new file mode 100644 index 00000000..7ce33c7c --- /dev/null +++ b/src/plugins/crossfade/index.ts @@ -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['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 | 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; + + const getStreamURL = async (videoID: string): Promise => 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('.next-button')?.click(); + } + }; + + video.addEventListener('timeupdate', transitionBeforeEnd); + }; + + const crossfade = (cb: () => void) => { + if (!isReadyToCrossfade()) { + cb(); + return; + } + + let resolveTransition: () => void; + waitForTransition = new Promise((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); + }); + } + } +}); diff --git a/src/plugins/crossfade/main.ts b/src/plugins/crossfade/main.ts deleted file mode 100644 index 56a1355f..00000000 --- a/src/plugins/crossfade/main.ts +++ /dev/null @@ -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); - }); -}; diff --git a/src/plugins/crossfade/menu.ts b/src/plugins/crossfade/menu.ts deleted file mode 100644 index d20d9def..00000000 --- a/src/plugins/crossfade/menu.ts +++ /dev/null @@ -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> | 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], - }; -} diff --git a/src/plugins/crossfade/renderer.ts b/src/plugins/crossfade/renderer.ts deleted file mode 100644 index cc96d4e6..00000000 --- a/src/plugins/crossfade/renderer.ts +++ /dev/null @@ -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; - -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; - -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('.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((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, - }); -}; diff --git a/src/plugins/disable-autoplay/index.ts b/src/plugins/disable-autoplay/index.ts new file mode 100644 index 00000000..101adbb0 --- /dev/null +++ b/src/plugins/disable-autoplay/index.ts @@ -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) => 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) { + if (this.config?.applyOnce) { + document.removeEventListener('videodatachange', this.eventListener); + } + + if (event.detail.name === 'dataloaded') { + this.api?.pauseVideo(); + document.querySelector('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; + } + } +}); + diff --git a/src/plugins/disable-autoplay/menu.ts b/src/plugins/disable-autoplay/menu.ts deleted file mode 100644 index 44575929..00000000 --- a/src/plugins/disable-autoplay/menu.ts +++ /dev/null @@ -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, - }); - } - } -]; diff --git a/src/plugins/disable-autoplay/renderer.ts b/src/plugins/disable-autoplay/renderer.ts deleted file mode 100644 index 0af23a4d..00000000 --- a/src/plugins/disable-autoplay/renderer.ts +++ /dev/null @@ -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('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); - } - }; - apiEvent.detail.addEventListener('videodatachange', eventListener); - }, { once: true, passive: true }); -}; diff --git a/src/plugins/discord/index.ts b/src/plugins/discord/index.ts new file mode 100644 index 00000000..5300f3ab --- /dev/null +++ b/src/plugins/discord/index.ts @@ -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, +}); + diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index ea7d26ea..6c82bb14 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -4,9 +4,12 @@ import { dev } from 'electron-is'; 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 const clientId = '1177081335727267940'; @@ -51,7 +54,6 @@ const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => { info.rpc.login().then(resolve).catch(reject); }, 5000)); - const connectRecursive = () => { if (!info.autoReconnect || info.rpc.isConnected) { return; @@ -92,53 +94,35 @@ export const connect = (showError = false) => { }; let clearActivity: NodeJS.Timeout | undefined; -let updateActivity: SongInfoCallback; -type DiscordOptions = ConfigType<'discord'>; +export const clear = () => { + if (info.rpc) { + info.rpc.user?.clearActivity(); + } -export default ( - win: Electron.BrowserWindow, - options: DiscordOptions, -) => { - info.rpc.on('connected', () => { - if (dev()) { - console.log('discord connected'); - } + clearTimeout(clearActivity); +}; - for (const cb of refreshCallbacks) { - cb(); - } - }); +export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb); +export const isConnected = () => info.rpc !== null; - info.rpc.on('ready', () => { - info.ready = true; - if (info.lastSongInfo) { - updateActivity(info.lastSongInfo); - } - }); - - info.rpc.on('disconnected', () => { - resetInfo(); - - 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) => { +export const backend = createBackend<{ + config?: DiscordPluginConfig; + updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void; +}, DiscordPluginConfig>({ + /** + * We get multiple events + * Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) + * Skip time: PAUSE(N), PLAY(N) + */ + updateActivity: (songInfo, config) => { if (songInfo.title.length === 0 && songInfo.artist.length === 0) { return; } info.lastSongInfo = songInfo; - // Stop the clear activity timout + // Stop the clear activity timeout clearTimeout(clearActivity); // Stop early if discord connection is not ready @@ -148,7 +132,7 @@ export default ( } // 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); return; } @@ -170,8 +154,11 @@ export default ( largeImageKey: songInfo.imageSrc ?? '', largeImageText: songInfo.album ?? '', buttons: [ - ...(options.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.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []), + ...(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.smallImageText = 'Paused'; // Set start the timer so the activity gets cleared after a while if enabled - if (options.activityTimoutEnabled) { - clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000); + if (config.activityTimeoutEnabled) { + 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 const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); activityInfo.startTimestamp = songStartTime; @@ -192,39 +179,70 @@ export default ( } 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 - win.once('ready-to-show', () => { - let lastSongInfo: SongInfo; - registerCallback((songInfo) => { - lastSongInfo = songInfo; - updateActivity(songInfo); - }); - 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); - } + info.rpc.on('connected', () => { + if (dev()) { + console.log('discord connected'); + } + + for (const cb of refreshCallbacks) { + cb(); } }); - }); - app.on('window-all-closed', clear); -}; -export const clear = () => { - if (info.rpc) { - info.rpc.user?.clearActivity(); - } + info.rpc.on('ready', () => { + info.ready = true; + 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); -export const isConnected = () => info.rpc !== null; + if (info.autoReconnect) { + 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); + } + }, +}); diff --git a/src/plugins/discord/menu.ts b/src/plugins/discord/menu.ts index def34b2f..4fe35d80 100644 --- a/src/plugins/discord/menu.ts +++ b/src/plugins/discord/menu.ts @@ -2,21 +2,22 @@ import prompt from 'custom-electron-prompt'; import { clear, connect, isConnected, registerRefresh } from './main'; -import { setMenuOptions } from '../../config/plugins'; -import promptOptions from '../../providers/prompt-options'; -import { singleton } from '../../providers/decorators'; -import { MenuTemplate } from '../../menu'; +import { singleton } from '@/providers/decorators'; +import promptOptions from '@/providers/prompt-options'; +import { setMenuOptions } from '@/config/plugins'; -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) => { registerRefresh(refreshMenu); }); -type DiscordOptions = ConfigType<'discord'>; - -export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => { - registerRefreshOnce(refreshMenu); +export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext): Promise => { + const config = await getConfig(); + registerRefreshOnce(refresh); return [ { @@ -27,10 +28,11 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen { label: 'Auto reconnect', type: 'checkbox', - checked: options.autoReconnect, + checked: config.autoReconnect, click(item: Electron.MenuItem) { - options.autoReconnect = item.checked; - setMenuOptions('discord', options); + setConfig({ + autoReconnect: item.checked, + }); }, }, { @@ -40,51 +42,55 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen { label: 'Clear activity after timeout', type: 'checkbox', - checked: options.activityTimoutEnabled, + checked: config.activityTimeoutEnabled, click(item: Electron.MenuItem) { - options.activityTimoutEnabled = item.checked; - setMenuOptions('discord', options); + setConfig({ + activityTimeoutEnabled: item.checked, + }); }, }, { label: 'Play on YouTube Music', type: 'checkbox', - checked: options.playOnYouTubeMusic, + checked: config.playOnYouTubeMusic, click(item: Electron.MenuItem) { - options.playOnYouTubeMusic = item.checked; - setMenuOptions('discord', options); + setConfig({ + playOnYouTubeMusic: item.checked, + }); }, }, { label: 'Hide GitHub link Button', type: 'checkbox', - checked: options.hideGitHubButton, + checked: config.hideGitHubButton, click(item: Electron.MenuItem) { - options.hideGitHubButton = item.checked; - setMenuOptions('discord', options); + setConfig({ + hideGitHubButton: item.checked, + }); }, }, { label: 'Hide duration left', type: 'checkbox', - checked: options.hideDurationLeft, + checked: config.hideDurationLeft, click(item: Electron.MenuItem) { - options.hideDurationLeft = item.checked; - setMenuOptions('discord', options); + setConfig({ + hideGitHubButton: item.checked, + }); }, }, { 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({ title: 'Set Inactivity Timeout', label: 'Enter inactivity timeout in seconds:', - value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)), + value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)), type: 'counter', counterOptions: { minimum: 0, multiFire: true }, width: 450, @@ -92,7 +98,7 @@ async function setInactivityTimeout(win: Electron.BrowserWindow, options: Discor }, win); if (output) { - options.activityTimoutTime = Math.round(~~output * 1e3); + options.activityTimeoutTime = Math.round(~~output * 1e3); setMenuOptions('discord', options); } } diff --git a/src/plugins/downloader/config.ts b/src/plugins/downloader/config.ts deleted file mode 100644 index 69b1cb78..00000000 --- a/src/plugins/downloader/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('downloader'); -export default config; diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts new file mode 100644 index 00000000..93a0ee5f --- /dev/null +++ b/src/plugins/downloader/index.ts @@ -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, + } +}); + diff --git a/src/plugins/downloader/main.ts b/src/plugins/downloader/main/index.ts similarity index 90% rename from src/plugins/downloader/main.ts rename to src/plugins/downloader/main/index.ts index 815262b6..fe2d4a27 100644 --- a/src/plugins/downloader/main.ts +++ b/src/plugins/downloader/main/index.ts @@ -7,7 +7,7 @@ import { import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { app, BrowserWindow, dialog, ipcMain, net } from 'electron'; +import { app, BrowserWindow, dialog } from 'electron'; import { ClientType, Innertube, @@ -27,16 +27,18 @@ import { sendFeedback as sendFeedback_, setBadge, } 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 { isEnabled } from '../../config/plugins'; -import { cleanupName, getImage, SongInfo } from '../../providers/song-info'; -import { injectCSS } from '../utils/main'; -import { cache } from '../../providers/decorators'; +import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types'; + +import type { DownloaderPluginConfig } from '../index'; + +import type { BackendContext } from '@/types/contexts'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; 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 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 }; @@ -89,41 +91,27 @@ export const getCookieFromWindow = async (win: BrowserWindow) => { .join(';'); }; -export default async (win_: BrowserWindow) => { - win = win_; - injectCSS(win.webContents, style); +let config: DownloaderPluginConfig; + +export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext) => { + win = _win; + config = await getConfig(); yt = await Innertube.create({ cache: new UniversalCache(false), cookie: await getCookieFromWindow(win), generate_session_locally: true, - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { - 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, + fetch: getNetFetchAsFetch(), }); - ipcMain.on('download-song', (_, url: string) => downloadSong(url)); - ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => { + ipc.handle('download-song', (url: string) => downloadSong(url)); + ipc.on('video-src-changed', (data: GetPlayerResponse) => { playingUrl = data.microformat.microformatDataRenderer.urlCanonical; }); - ipcMain.on('download-playlist-request', async (_event, url: string) => - downloadPlaylist(url), - ); + ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url)); +}; + +export const onConfigChange = (newConfig: DownloaderPluginConfig) => { + config = newConfig; }; export async function downloadSong( @@ -209,7 +197,7 @@ async function downloadSongUnsafe( metadata.trackId = trackId; const dir = - playlistFolder || config.get('downloadFolder') || app.getPath('downloads'); + playlistFolder || config.downloadFolder || app.getPath('downloads'); const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${ 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; if (selectedPreset === 'Custom') { presetSetting = - config.get('customPresetSetting') ?? DefaultPresetList['Custom']; + config.customPresetSetting ?? DefaultPresetList['Custom']; } else if (selectedPreset === 'Source') { presetSetting = DefaultPresetList['Source']; } else { @@ -276,7 +264,7 @@ async function downloadSongUnsafe( } const filePath = join(dir, filename); - if (config.get('skipExisting') && existsSync(filePath)) { + if (config.skipExisting && existsSync(filePath)) { sendFeedback(null, -1); return; } @@ -330,7 +318,7 @@ async function iterableStreamToTargetFile( contentLength: number, sendFeedback: (str: string, value?: number) => void, increasePlaylistProgress: (value: number) => void = () => {}, -) { +): Promise { const chunks = []; let downloaded = 0; for await (const chunk of stream) { @@ -390,6 +378,7 @@ async function iterableStreamToTargetFile( } finally { releaseFFmpegMutex(); } + return null; } const getCoverBuffer = cache(async (url: string) => { @@ -517,10 +506,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) { safePlaylistTitle = safePlaylistTitle.normalize('NFC'); } - const folder = getFolder(config.get('downloadFolder') ?? ''); + const folder = getFolder(config.downloadFolder ?? ''); const playlistFolder = join(folder, safePlaylistTitle); if (existsSync(playlistFolder)) { - if (!config.get('skipExisting')) { + if (!config.skipExisting) { sendError(new Error(`The folder ${playlistFolder} already exists`)); return; } @@ -637,6 +626,7 @@ const getAndroidTvInfo = async (id: string): Promise => { client_type: ClientType.TV_EMBEDDED, generate_session_locally: true, retrieve_player: true, + fetch: getNetFetchAsFetch(), }); // GetInfo 404s with the bypass, so we use getBasicInfo instead // that's fine as we only need the streaming data diff --git a/src/plugins/downloader/utils.ts b/src/plugins/downloader/main/utils.ts similarity index 100% rename from src/plugins/downloader/utils.ts rename to src/plugins/downloader/main/utils.ts diff --git a/src/plugins/downloader/menu.ts b/src/plugins/downloader/menu.ts index 199612a4..2b95dcb7 100644 --- a/src/plugins/downloader/menu.ts +++ b/src/plugins/downloader/menu.ts @@ -1,46 +1,52 @@ import { dialog } from 'electron'; import { downloadPlaylist } from './main'; -import { defaultMenuDownloadLabel, getFolder } from './utils'; +import { defaultMenuDownloadLabel, getFolder } from './main/utils'; 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 => [ - { - label: defaultMenuDownloadLabel, - click: () => downloadPlaylist(), - }, - { - label: 'Choose download folder', - click() { - const result = dialog.showOpenDialogSync({ - properties: ['openDirectory', 'createDirectory'], - defaultPath: getFolder(config.get('downloadFolder') ?? ''), - }); - if (result) { - config.set('downloadFolder', result[0]); - } // Else = user pressed cancel +import type { DownloaderPluginConfig } from './index'; + +export const onMenu = async ({ getConfig, setConfig }: MenuContext): Promise => { + const config = await getConfig(); + + return [ + { + label: defaultMenuDownloadLabel, + click: () => downloadPlaylist(), }, - }, - { - label: 'Presets', - submenu: Object.keys(DefaultPresetList).map((preset) => ({ - label: preset, - type: 'radio', - checked: config.get('selectedPreset') === preset, + { + label: 'Choose download folder', 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 }); + }, + }, + ]; +}; diff --git a/src/plugins/downloader/renderer.ts b/src/plugins/downloader/renderer.ts index 5c6bcd50..aa7a2d4c 100644 --- a/src/plugins/downloader/renderer.ts +++ b/src/plugins/downloader/renderer.ts @@ -1,9 +1,14 @@ import downloadHTML from './templates/download.html?raw'; -import defaultConfig from '../../config/defaults'; -import { getSongMenu } from '../../providers/dom-elements'; +import defaultConfig from '@/config/defaults'; +import { getSongMenu } from '@/providers/dom-elements'; +import { getSongInfo } from '@/providers/song-info-front'; + 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 progress: Element | null = null; @@ -11,34 +16,34 @@ const downloadButton = ElementFromHtml(downloadHTML); let doneFirstLoad = false; -export default () => { - const menuObserver = new MutationObserver(() => { +const menuObserver = new MutationObserver(() => { + if (!menu) { + menu = getSongMenu(); if (!menu) { - menu = getSongMenu(); - if (!menu) { - return; - } - } - - if (menu.contains(downloadButton)) { return; } + } - const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; - if (!menuUrl?.includes('watch?') && doneFirstLoad) { - return; - } + if (menu.contains(downloadButton)) { + return; + } - menu.prepend(downloadButton); - progress = document.querySelector('#ytmcustom-download'); + const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; + if (!menuUrl?.includes('watch?') && doneFirstLoad) { + return; + } - if (doneFirstLoad) { - return; - } + menu.prepend(downloadButton); + progress = document.querySelector('#ytmcustom-download'); - setTimeout(() => doneFirstLoad ||= true, 500); - }); + if (doneFirstLoad) { + return; + } + setTimeout(() => doneFirstLoad ||= true, 500); +}); + +export const onRendererLoad = ({ ipc }: RendererContext) => { window.download = () => { let videoUrl = getSongMenu() // Selector of first button which is always "Start Radio" @@ -50,24 +55,17 @@ export default () => { } if (videoUrl.includes('?playlist=')) { - window.ipcRenderer.send('download-playlist-request', videoUrl); + ipc.invoke('download-playlist-request', videoUrl); return; } } else { videoUrl = getSongInfo().url || window.location.href; } - window.ipcRenderer.send('download-song', videoUrl); + ipc.invoke('download-song', videoUrl); }; - document.addEventListener('apiLoaded', () => { - menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { - childList: true, - subtree: true, - }); - }, { once: true, passive: true }); - - window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => { + ipc.on('downloader-feedback', (feedback: string) => { if (progress) { progress.innerHTML = feedback || 'Download'; } else { @@ -75,3 +73,10 @@ export default () => { } }); }; + +export const onPlayerApiReady = () => { + menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { + childList: true, + subtree: true, + }); +}; diff --git a/src/plugins/exponential-volume/index.ts b/src/plugins/exponential-volume/index.ts new file mode 100644 index 00000000..858e2f50 --- /dev/null +++ b/src/plugins/exponential-volume/index.ts @@ -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(); + 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); + }, + }); + } + } +}); diff --git a/src/plugins/exponential-volume/renderer.ts b/src/plugins/exponential-volume/renderer.ts deleted file mode 100644 index 3f2c5d0c..00000000 --- a/src/plugins/exponential-volume/renderer.ts +++ /dev/null @@ -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(); - 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, - }); diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts new file mode 100644 index 00000000..a8b06c37 --- /dev/null +++ b/src/plugins/in-app-menu/index.ts @@ -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, + }, +}); + diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts index a97177cd..6719e4d6 100644 --- a/src/plugins/in-app-menu/main.ts +++ b/src/plugins/in-app-menu/main.ts @@ -2,25 +2,21 @@ import { register } from 'electron-localshortcut'; import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; -import titlebarStyle from './titlebar.css'; - -import { injectCSS } from '../utils/main'; - -// Tracks menu visibility -export default (win: BrowserWindow) => { - injectCSS(win.webContents, titlebarStyle); +import type { BackendContext } from '@/types/contexts'; +import type { InAppMenuConfig } from './index'; +export const onMainLoad = ({ window: win, ipc: { handle, send } }: BackendContext) => { win.on('close', () => { - win.webContents.send('close-all-in-app-menu-panel'); + send('close-all-in-app-menu-panel'); }); win.once('ready-to-show', () => { register(win, '`', () => { - win.webContents.send('toggle-in-app-menu'); + send('toggle-in-app-menu'); }); }); - ipcMain.handle( + handle( 'get-menu', () => JSON.parse(JSON.stringify( Menu.getApplicationMenu(), @@ -51,7 +47,7 @@ export default (win: BrowserWindow) => { 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); 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()); - ipcMain.handle('window-minimize', () => win.minimize()); - ipcMain.handle('window-maximize', () => win.maximize()); - win.on('maximize', () => win.webContents.send('window-maximize')); - ipcMain.handle('window-unmaximize', () => win.unmaximize()); - win.on('unmaximize', () => win.webContents.send('window-unmaximize')); + handle('window-close', () => win.close()); + handle('window-minimize', () => win.minimize()); + handle('window-maximize', () => win.maximize()); + win.on('maximize', () => send('window-maximize')); + handle('window-unmaximize', () => win.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); return nativeImageIcon?.toDataURL(); }); diff --git a/src/plugins/in-app-menu/menu.ts b/src/plugins/in-app-menu/menu.ts index 3ed2fed2..3aa871a6 100644 --- a/src/plugins/in-app-menu/menu.ts +++ b/src/plugins/in-app-menu/menu.ts @@ -1,22 +1,25 @@ -import { BrowserWindow } from 'electron'; - 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'; -import type { ConfigType } from '../../config/dynamic'; +export const onMenu = async ({ getConfig, setConfig }: MenuContext): Promise => { + const config = await getConfig(); -export default (_: BrowserWindow, config: ConfigType<'in-app-menu'>): MenuTemplate => [ - ...(is.linux() ? [ - { - label: 'Hide DOM Window Controls', - type: 'checkbox', - checked: config.hideDOMWindowControls, - click(item) { - config.hideDOMWindowControls = item.checked; - setMenuOptions('in-app-menu', config); + if (is.linux()) { + return [ + { + label: 'Hide DOM Window Controls', + type: 'checkbox', + checked: config.hideDOMWindowControls, + click(item) { + config.hideDOMWindowControls = item.checked; + setConfig(config); + } } - } - ] : []) satisfies Electron.MenuItemConstructorOptions[], -]; + ]; + } + + return []; +}; diff --git a/src/plugins/in-app-menu/renderer.ts b/src/plugins/in-app-menu/renderer.ts index 02a72c67..182d22db 100644 --- a/src/plugins/in-app-menu/renderer.ts +++ b/src/plugins/in-app-menu/renderer.ts @@ -8,15 +8,17 @@ import unmaximizeRaw from './assets/unmaximize.svg?inline'; import type { Menu } from 'electron'; -function $(selector: string) { - return document.querySelector(selector); -} +import type { RendererContext } from '@/types/contexts'; +import type { InAppMenuConfig } from '@/plugins/in-app-menu/index'; const isMacOS = navigator.userAgent.includes('Macintosh'); const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS; -export default async () => { - const hideDOMWindowControls = window.mainConfig.get('plugins.in-app-menu.hideDOMWindowControls'); +export const onRendererLoad = async ({ getConfig, ipc: { invoke, on } }: RendererContext) => { + const config = await getConfig(); + + const hideDOMWindowControls = config.hideDOMWindowControls; + let hideMenu = window.mainConfig.get('options.hideMenu'); const titleBar = document.createElement('title-bar'); const navBar = document.querySelector('#nav-bar-background'); @@ -60,7 +62,7 @@ export default async () => { }; logo.onclick = logoClick; - window.ipcRenderer.on('toggle-in-app-menu', logoClick); + on('toggle-in-app-menu', logoClick); if (!isMacOS) titleBar.appendChild(logo); document.body.appendChild(titleBar); @@ -73,10 +75,10 @@ export default async () => { const minimizeButton = document.createElement('button'); minimizeButton.classList.add('window-control'); minimizeButton.appendChild(minimize); - minimizeButton.onclick = () => window.ipcRenderer.invoke('window-minimize'); + minimizeButton.onclick = () => invoke('window-minimize'); maximizeButton = document.createElement('button'); - if (await window.ipcRenderer.invoke('window-is-maximized')) { + if (await invoke('window-is-maximized')) { maximizeButton.classList.add('window-control'); maximizeButton.appendChild(unmaximize); } else { @@ -84,27 +86,27 @@ export default async () => { maximizeButton.appendChild(maximize); } maximizeButton.onclick = async () => { - if (await window.ipcRenderer.invoke('window-is-maximized')) { + if (await invoke('window-is-maximized')) { // change icon to maximize maximizeButton.removeChild(maximizeButton.firstChild!); maximizeButton.appendChild(maximize); // call unmaximize - await window.ipcRenderer.invoke('window-unmaximize'); + await invoke('window-unmaximize'); } else { // change icon to unmaximize maximizeButton.removeChild(maximizeButton.firstChild!); maximizeButton.appendChild(unmaximize); // call maximize - await window.ipcRenderer.invoke('window-maximize'); + await invoke('window-maximize'); } }; const closeButton = document.createElement('button'); closeButton.classList.add('window-control'); closeButton.appendChild(close); - closeButton.onclick = () => window.ipcRenderer.invoke('window-close'); + closeButton.onclick = () => invoke('window-close'); // Create a container div for the window control buttons const windowControlsContainer = document.createElement('div'); @@ -137,7 +139,7 @@ export default async () => { }); panelClosers = []; - const menu = await window.ipcRenderer.invoke('get-menu') as Menu | null; + const menu = await invoke('get-menu') as Menu | null; if (!menu) return; menu.items.forEach((menuItem) => { @@ -157,17 +159,17 @@ export default async () => { document.title = 'Youtube Music'; - window.ipcRenderer.on('close-all-in-app-menu-panel', () => { + on('close-all-in-app-menu-panel', () => { panelClosers.forEach((closer) => closer()); }); - window.ipcRenderer.on('refresh-in-app-menu', () => updateMenu()); - window.ipcRenderer.on('window-maximize', () => { + on('refresh-in-app-menu', () => updateMenu()); + on('window-maximize', () => { if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { maximizeButton.removeChild(maximizeButton.firstChild); maximizeButton.appendChild(unmaximize); } }); - window.ipcRenderer.on('window-unmaximize', () => { + on('window-unmaximize', () => { if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { maximizeButton.removeChild(maximizeButton.firstChild); maximizeButton.appendChild(unmaximize); @@ -175,17 +177,16 @@ export default async () => { }); if (window.mainConfig.plugins.isEnabled('picture-in-picture')) { - window.ipcRenderer.on('pip-toggle', () => { + on('pip-toggle', () => { 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', () => { - const htmlHeadStyle = $('head > div > style'); - if (htmlHeadStyle) { - // 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 {'); - } - }, { once: true, passive: true }); +}; + +export const onPlayerApiReady = () => { + const htmlHeadStyle = document.querySelector('head > div > style'); + if (htmlHeadStyle) { + // 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 {'); + } }; diff --git a/src/plugins/last-fm/index.ts b/src/plugins/last-fm/index.ts new file mode 100644 index 00000000..54b7e11a --- /dev/null +++ b/src/plugins/last-fm/index.ts @@ -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); + } + } + }); + } +}); diff --git a/src/plugins/last-fm/main.ts b/src/plugins/last-fm/main.ts index b406bae2..07e887c3 100644 --- a/src/plugins/last-fm/main.ts +++ b/src/plugins/last-fm/main.ts @@ -1,14 +1,9 @@ import crypto from 'node:crypto'; -import { BrowserWindow, net, shell } from 'electron'; +import { net, shell } from 'electron'; -import { setOptions } from '../../config/plugins'; -import registerCallback, { SongInfo } from '../../providers/song-info'; -import defaultConfig from '../../config/defaults'; - -import type { ConfigType } from '../../config/dynamic'; - -type LastFMOptions = ConfigType<'last-fm'>; +import type { LastFmPluginConfig } from './index'; +import type { SongInfo } from '@/providers/song-info'; interface LastFmData { method: string, @@ -56,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => { keys.sort(); let sig = ''; for (const key of keys) { - if (String(key) === 'format') { + if (key === 'format') { continue; } @@ -68,7 +63,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => { 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 const data = { method: 'auth.gettoken', @@ -81,12 +76,14 @@ const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastF return json?.token; }; -const authenticate = async (config: LastFMOptions) => { +const authenticate = async (config: LastFmPluginConfig) => { // Asks the user for authentication 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>) => (void | Promise); + +export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => { // Get and store the session key const data = { api_key: config.api_key, @@ -105,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFMOptions) => { if (json.error) { config.token = await createToken(config); await authenticate(config); - setOptions('last-fm', config); + setConfig(config); } if (json.session) { config.session_key = json.session.key; } - setOptions('last-fm', config); + setConfig(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 if (!config.session_key) { - await getAndSetSessionKey(config); + await getAndSetSessionKey(config, setConfig); } const postData: LastFmSongData = { @@ -146,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data config.session_key = undefined; config.token = await createToken(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 const data = { method: 'track.scrobble', 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 const data = { 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; diff --git a/src/plugins/lumiastream/index.ts b/src/plugins/lumiastream/index.ts new file mode 100644 index 00000000..50e489d9 --- /dev/null +++ b/src/plugins/lumiastream/index.ts @@ -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); + }); + } +}); diff --git a/src/plugins/lumiastream/main.ts b/src/plugins/lumiastream/main.ts deleted file mode 100644 index a026dbac..00000000 --- a/src/plugins/lumiastream/main.ts +++ /dev/null @@ -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); - }); -}; - - diff --git a/src/plugins/lyrics-genius/index.ts b/src/plugins/lyrics-genius/index.ts new file mode 100644 index 00000000..71318b54 --- /dev/null +++ b/src/plugins/lyrics-genius/index.ts @@ -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, +}); diff --git a/src/plugins/lyrics-genius/main.ts b/src/plugins/lyrics-genius/main.ts index d8b58371..bd7840dd 100644 --- a/src/plugins/lyrics-genius/main.ts +++ b/src/plugins/lyrics-genius/main.ts @@ -1,36 +1,32 @@ -import { BrowserWindow, ipcMain, net } from 'electron'; +import { net } from 'electron'; import is from 'electron-is'; import { convert } from 'html-to-text'; -import style from './style.css'; 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 { ConfigType } from '../../config/dynamic'; +import type { BackendContext } from '@/types/contexts'; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u; let revRomanized = false; -export type LyricGeniusType = ConfigType<'lyrics-genius'>; +export const onMainLoad = async ({ ipc, getConfig }: BackendContext) => { + const config = await getConfig(); -export default (win: BrowserWindow, options: LyricGeniusType) => { - if (options.romanizedLyrics) { + if (config.romanizedLyrics) { revRomanized = true; } - injectCSS(win.webContents, style); - - ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => { + ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => { const metadata = extractedSongInfo; return await fetchFromGenius(metadata); }); }; -export const toggleRomanized = () => { - revRomanized = !revRomanized; +export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => { + revRomanized = newConfig.romanizedLyrics; }; export const fetchFromGenius = async (metadata: SongInfo) => { diff --git a/src/plugins/lyrics-genius/menu.ts b/src/plugins/lyrics-genius/menu.ts deleted file mode 100644 index d96e686d..00000000 --- a/src/plugins/lyrics-genius/menu.ts +++ /dev/null @@ -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(); - }, - }, -]; diff --git a/src/plugins/lyrics-genius/renderer.ts b/src/plugins/lyrics-genius/renderer.ts index c86b0ddd..2f74eb41 100644 --- a/src/plugins/lyrics-genius/renderer.ts +++ b/src/plugins/lyrics-genius/renderer.ts @@ -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) => { const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { lyricsContainer.innerHTML = `
@@ -21,7 +23,7 @@ export default () => { let unregister: (() => void) | null = null; - window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => { + on('update-song-info', (extractedSongInfo: SongInfo) => { unregister?.(); setTimeout(async () => { @@ -35,7 +37,7 @@ export default () => { // Check if disabled if (!tabs.lyrics?.hasAttribute('disabled')) return; - const lyrics = await window.ipcRenderer.invoke( + const lyrics = await invoke( 'search-genius-lyrics', extractedSongInfo, ) as string | null; diff --git a/src/plugins/navigation/front.ts b/src/plugins/navigation/index.ts similarity index 53% rename from src/plugins/navigation/front.ts rename to src/plugins/navigation/index.ts index 8cfcf45f..7238f4ad 100644 --- a/src/plugins/navigation/front.ts +++ b/src/plugins/navigation/index.ts @@ -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 backHTML from './templates/back.html?raw'; -import { ElementFromHtml } from '../utils/renderer'; - -export function run() { - window.ipcRenderer.on('navigation-css-ready', () => { +export default createPlugin({ + name: 'Navigation', + restartNeeded: true, + config: { + enabled: true, + }, + stylesheets: [style], + renderer() { const forwardButton = ElementFromHtml(forwardHTML); const backButton = ElementFromHtml(backHTML); const menu = document.querySelector('#right-content'); @@ -12,7 +20,5 @@ export function run() { if (menu) { menu.prepend(backButton, forwardButton); } - }); -} - -export default run; + }, +}); diff --git a/src/plugins/navigation/main.ts b/src/plugins/navigation/main.ts deleted file mode 100644 index 51dceb4c..00000000 --- a/src/plugins/navigation/main.ts +++ /dev/null @@ -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; diff --git a/src/plugins/no-google-login/front.ts b/src/plugins/no-google-login/front.ts deleted file mode 100644 index b0f4158d..00000000 --- a/src/plugins/no-google-login/front.ts +++ /dev/null @@ -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; diff --git a/src/plugins/no-google-login/index.ts b/src/plugins/no-google-login/index.ts new file mode 100644 index 00000000..db8320ba --- /dev/null +++ b/src/plugins/no-google-login/index.ts @@ -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, + }); + } +}); diff --git a/src/plugins/no-google-login/main.ts b/src/plugins/no-google-login/main.ts deleted file mode 100644 index 1d313bf0..00000000 --- a/src/plugins/no-google-login/main.ts +++ /dev/null @@ -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); -}; diff --git a/src/plugins/notifications/config.ts b/src/plugins/notifications/config.ts deleted file mode 100644 index 91605f7a..00000000 --- a/src/plugins/notifications/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('notifications'); - -export default config; diff --git a/src/plugins/notifications/index.ts b/src/plugins/notifications/index.ts new file mode 100644 index 00000000..cb927d9a --- /dev/null +++ b/src/plugins/notifications/index.ts @@ -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, + }, +}); diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index df483f8b..2814e95c 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -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 config from './config'; -import getSongControls from '../../providers/song-controls'; -import registerCallback, { SongInfo } from '../../providers/song-info'; -import { changeProtocolHandler } from '../../providers/protocol-handler'; -import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray'; -import { mediaIcons } from '../../types/media-icons'; +import getSongControls from '@/providers/song-controls'; +import registerCallback, { SongInfo } from '@/providers/song-info'; +import { changeProtocolHandler } from '@/providers/protocol-handler'; +import { setTrayOnClick, setTrayOnDoubleClick } from '@/tray'; +import { mediaIcons } from '@/types/media-icons'; -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 type { NotificationsPluginConfig } from './index'; +import type { BackendContext } from '@/types/contexts'; let songControls: ReturnType; let savedNotification: Notification | undefined; -export default (win: BrowserWindow) => { +type Accessor = () => T; + +export default ( + win: BrowserWindow, + config: Accessor, + { ipc: { on, send } }: BackendContext, +) => { + 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) => + ``; + + const getButtons = (isPaused: boolean) => `\ + + ${getButton('previous')} + ${isPaused ? getButton('play') : getButton('pause')} + ${getButton('next')} + \ +`; + + const toast = (content: string, isPaused: boolean) => `\ + + `; + + const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\ + + ${title} + ${artist}\ +`, 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(`\ + + + + + ${songInfo.title} + ${songInfo.artist} + + ${xmlMoreData(songInfo)} + \ +`, songInfo.isPaused ?? false); + + const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\ + + ${album + ? `${album}` : ''} + ${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)} +\ +`; + + const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ + + + + ${title} + ${artist} + + + \ +`, isPaused ?? false); + + const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ + + + + + ${title} + ${artist} + + \ +`, 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); 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 lastUrl: string | undefined; @@ -36,14 +227,14 @@ export default (win: BrowserWindow) => { savedSongInfo = { ...songInfo }; if (!songInfo.isPaused - && (songInfo.url !== lastUrl || config.get('unpauseNotification')) + && (songInfo.url !== lastUrl || config().unpauseNotification) ) { lastUrl = songInfo.url; sendNotification(songInfo); } }); - if (config.get('trayControls')) { + if (config().trayControls) { setTrayOnClick(() => { if (savedNotification) { savedNotification.close(); @@ -73,9 +264,9 @@ export default (win: BrowserWindow) => { (cmd) => { if (Object.keys(songControls).includes(cmd)) { songControls[cmd as keyof typeof songControls](); - if (config.get('refreshOnPlayPause') && ( + if (config().refreshOnPlayPause && ( cmd === 'pause' - || (cmd === 'play' && !config.get('unpauseNotification')) + || (cmd === 'play' && !config().unpauseNotification) ) ) { 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) => - ``; - -const getButtons = (isPaused: boolean) => `\ - - ${getButton('previous')} - ${isPaused ? getButton('play') : getButton('pause')} - ${getButton('next')} - \ -`; - -const toast = (content: string, isPaused: boolean) => `\ - - `; - -const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\ - - ${title} - ${artist}\ -`, 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(`\ - - - - - ${songInfo.title} - ${songInfo.artist} - - ${xmlMoreData(songInfo)} - \ -`, songInfo.isPaused ?? false); - -const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\ - - ${album - ? `${album}` : ''} - ${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)} -\ -`; - -const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ - - - - ${title} - ${artist} - - - \ -`, isPaused ?? false); - -const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ - - - - - ${title} - ${artist} - - \ -`, 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'; -}; diff --git a/src/plugins/notifications/main.ts b/src/plugins/notifications/main.ts index bc73819d..c4b4481b 100644 --- a/src/plugins/notifications/main.ts +++ b/src/plugins/notifications/main.ts @@ -1,25 +1,25 @@ -import { BrowserWindow, Notification } from 'electron'; +import { Notification } from 'electron'; import is from 'electron-is'; import { notificationImage } from './utils'; -import config from './config'; import interactive from './interactive'; -import registerCallback, { SongInfo } from '../../providers/song-info'; +import 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) => { // Send the notification const currentNotification = new Notification({ title: info.title || 'Playing', body: info.artist, - icon: notificationImage(info), + icon: notificationImage(info, config), silent: true, - urgency: config.get('urgency') as 'normal' | 'critical' | 'low', + urgency: config.urgency, }); currentNotification.show(); @@ -31,7 +31,7 @@ const setup = () => { let currentUrl: string | undefined; registerCallback((songInfo: SongInfo) => { - if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) { + if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.unpauseNotification)) { // Close the old notification oldNotification?.close(); currentUrl = songInfo.url; @@ -43,9 +43,14 @@ const setup = () => { }); }; -export default (win: BrowserWindow, options: NotificationOptions) => { +export const onMainLoad = async (context: BackendContext) => { + config = await context.getConfig(); + // Register the callback for new song information - is.windows() && options.interactive - ? interactive(win) - : setup(); + if (is.windows() && config.interactive) interactive(context.window, () => config, context); + else setup(); +}; + +export const onConfigChange = (newConfig: NotificationsPluginConfig) => { + config = newConfig; }; diff --git a/src/plugins/notifications/menu.ts b/src/plugins/notifications/menu.ts index 9fd51a03..1b603049 100644 --- a/src/plugins/notifications/menu.ts +++ b/src/plugins/notifications/menu.ts @@ -1,93 +1,95 @@ import is from 'electron-is'; - -import { BrowserWindow, MenuItem } from 'electron'; +import { MenuItem } from 'electron'; 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): Promise => { + const config = await getConfig(); -const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => { - if (is.linux()) { - return [ - { - label: 'Notification Priority', - submenu: urgencyLevels.map((level) => ({ - label: level.name, - type: 'radio', - checked: options.urgency === level.value, - click: () => config.set('urgency', level.value), - })), - } - ]; - } else if (is.windows()) { - return [ - { - label: 'Interactive Notifications', - type: 'checkbox', - checked: options.interactive, - // Doesn't update until restart - click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked), - }, - { - // Submenu with settings for interactive notifications (name shouldn't be too long) - label: 'Interactive Settings', - submenu: [ - { - label: 'Open/Close on tray click', - type: 'checkbox', - checked: options.trayControls, - click: (item: MenuItem) => config.set('trayControls', item.checked), - }, - { - label: 'Hide Button Text', - type: 'checkbox', - checked: options.hideButtonText, - click: (item: MenuItem) => config.set('hideButtonText', item.checked), - }, - { - label: 'Refresh on Play/Pause', - type: 'checkbox', - checked: options.refreshOnPlayPause, - click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked), - }, - ], - }, - { - label: 'Style', - submenu: getToastStyleMenuItems(options), - }, - ]; - } else { - return []; - } + const getToastStyleMenuItems = (options: NotificationsPluginConfig) => { + 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: () => setConfig({ toastStyle: index }), + } satisfies Electron.MenuItemConstructorOptions; + } + + return array as Electron.MenuItemConstructorOptions[]; + }; + + const getMenu = (): MenuTemplate => { + if (is.linux()) { + return [ + { + label: 'Notification Priority', + submenu: urgencyLevels.map((level) => ({ + label: level.name, + type: 'radio', + checked: config.urgency === level.value, + click: () => setConfig({ urgency: level.value }), + })), + } + ]; + } else if (is.windows()) { + return [ + { + label: 'Interactive Notifications', + type: 'checkbox', + checked: config.interactive, + // Doesn't update until restart + click: (item: MenuItem) => setConfig({ interactive: item.checked }), + }, + { + // Submenu with settings for interactive notifications (name shouldn't be too long) + label: 'Interactive Settings', + submenu: [ + { + label: 'Open/Close on tray click', + type: 'checkbox', + checked: config.trayControls, + click: (item: MenuItem) => setConfig({ trayControls: item.checked }), + }, + { + label: 'Hide Button Text', + type: 'checkbox', + checked: config.hideButtonText, + 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[]; -} diff --git a/src/plugins/notifications/utils.ts b/src/plugins/notifications/utils.ts index d51bce2a..1c88fe63 100644 --- a/src/plugins/notifications/utils.ts +++ b/src/plugins/notifications/utils.ts @@ -3,13 +3,12 @@ import fs from 'node:fs'; import { app, NativeImage } from 'electron'; -import config from './config'; +import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack'; -import { cache } from '../../providers/decorators'; -import { SongInfo } from '../../providers/song-info'; - -import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack'; +import { cache } from '@/providers/decorators'; +import { SongInfo } from '@/providers/song-info'; +import type { NotificationsPluginConfig } from './index'; const userData = app.getPath('userData'); const temporaryIcon = path.join(userData, 'tempIcon.png'); @@ -27,9 +26,9 @@ export const ToastStyles = { }; export const urgencyLevels = [ - { name: 'Low', value: 'low' }, - { name: 'Normal', value: 'normal' }, - { name: 'High', value: 'critical' }, + { name: 'Low', value: 'low' } as const, + { name: 'Normal', value: 'normal' } as const, + { name: 'High', value: 'critical' } as const, ]; 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) { return youtubeMusicIcon; } - if (!config.get('interactive')) { + if (!config.interactive) { return nativeImageToLogo(songInfo.image); } - switch (config.get('toastStyle')) { + switch (config.toastStyle) { case ToastStyles.logo: case ToastStyles.legacy: { return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon); diff --git a/src/plugins/picture-in-picture/index.ts b/src/plugins/picture-in-picture/index.ts new file mode 100644 index 00000000..198b4fb6 --- /dev/null +++ b/src/plugins/picture-in-picture/index.ts @@ -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, + } +}); diff --git a/src/plugins/picture-in-picture/main.ts b/src/plugins/picture-in-picture/main.ts index e1fad077..abbea0d7 100644 --- a/src/plugins/picture-in-picture/main.ts +++ b/src/plugins/picture-in-picture/main.ts @@ -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 { setOptions as setPluginOptions } from '../../config/plugins'; +import type { BackendContext } from '@/types/contexts'; -import type { ConfigType } from '../../config/dynamic'; +let config: PictureInPicturePluginConfig; -let isInPiP = false; -let originalPosition: number[]; -let originalSize: number[]; -let originalFullScreen: boolean; -let originalMaximized: boolean; +export const onMainLoad = async ({ window, getConfig, setConfig, ipc: { send, handle, on } }: BackendContext) => { + let isInPiP = false; + let originalPosition: number[]; + let originalSize: number[]; + 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; + if (isInPiP) { + originalFullScreen = window.isFullScreen(); + if (originalFullScreen) { + window.setFullScreen(false); + } -const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10]; -const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275]; + originalMaximized = window.isMaximized(); + if (originalMaximized) { + window.unmaximize(); + } -const setLocalOptions = (_options: Partial) => { - options = { ...options, ..._options }; - setPluginOptions('picture-in-picture', _options); -}; + originalPosition = window.getPosition(); + originalSize = window.getSize(); -const togglePiP = () => { - isInPiP = !isInPiP; - setLocalOptions({ isInPiP }); + handle('before-input-event', blockShortcutsInPiP); - if (isInPiP) { - originalFullScreen = win.isFullScreen(); - if (originalFullScreen) { - win.setFullScreen(false); + window.setMaximizable(false); + window.setFullScreenable(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(); - if (originalMaximized) { - win.unmaximize(); + const [x, y] = isInPiP ? pipPosition() : originalPosition; + const [w, h] = isInPiP ? pipSize() : originalSize; + 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(); - originalSize = win.getSize(); - - 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') { + config ??= await getConfig(); + setConfig({ isInPiP }); + on('picture-in-picture', () => { togglePiP(); - event.preventDefault(); - } -}; + }); -export default (_win: BrowserWindow, _options: PiPOptions) => { - options ??= _options; - win ??= _win; - setLocalOptions({ isInPiP }); - injectCSS(win.webContents, style); - ipcMain.on('picture-in-picture', () => { - togglePiP(); + window.on('move', () => { + if (config.isInPiP && !config.useNativePiP) { + setConfig({ 'pip-position': window.getPosition() as [number, number] }); + } + }); + + 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; +}; diff --git a/src/plugins/picture-in-picture/menu.ts b/src/plugins/picture-in-picture/menu.ts index f62a9763..35f55619 100644 --- a/src/plugins/picture-in-picture/menu.ts +++ b/src/plugins/picture-in-picture/menu.ts @@ -1,75 +1,77 @@ 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): Promise => { + const config = await getConfig(); -export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [ - { - label: 'Always on top', - type: 'checkbox', - checked: options.alwaysOnTop, - click(item) { - setOptions({ alwaysOnTop: item.checked }); - win.setAlwaysOnTop(item.checked); + return [ + { + label: 'Always on top', + type: 'checkbox', + checked: config.alwaysOnTop, + click(item) { + setConfig({ alwaysOnTop: item.checked }); + window.setAlwaysOnTop(item.checked); + }, }, - }, - { - label: 'Save window position', - type: 'checkbox', - checked: options.savePosition, - click(item) { - setOptions({ savePosition: item.checked }); + { + label: 'Save window position', + type: 'checkbox', + checked: config.savePosition, + click(item) { + setConfig({ savePosition: item.checked }); + }, }, - }, - { - label: 'Save window size', - type: 'checkbox', - checked: options.saveSize, - click(item) { - setOptions({ saveSize: item.checked }); + { + label: 'Save window size', + type: 'checkbox', + checked: config.saveSize, + click(item) { + setConfig({ saveSize: item.checked }); + }, }, - }, - { - label: 'Hotkey', - type: 'checkbox', - checked: !!options.hotkey, - async click(item) { - const output = await prompt({ - title: 'Picture in Picture Hotkey', - label: 'Choose a hotkey for toggling Picture in Picture', - type: 'keybind', - keybindOptions: [{ - value: 'hotkey', - label: 'Hotkey', - default: options.hotkey, - }], - ...promptOptions(), - }, win); + { + label: 'Hotkey', + type: 'checkbox', + checked: !!config.hotkey, + async click(item) { + const output = await prompt({ + title: 'Picture in Picture Hotkey', + label: 'Choose a hotkey for toggling Picture in Picture', + type: 'keybind', + keybindOptions: [{ + value: 'hotkey', + label: 'Hotkey', + default: config.hotkey, + }], + ...promptOptions(), + }, window); - if (output) { - const { value, accelerator } = output[0]; - setOptions({ [value]: accelerator }); + if (output) { + const { value, accelerator } = output[0]; + setConfig({ [value]: accelerator }); - item.checked = !!accelerator; - } else { - // Reset checkbox if prompt was canceled - item.checked = !item.checked; - } + item.checked = !!accelerator; + } else { + // Reset checkbox if prompt was canceled + item.checked = !item.checked; + } + }, }, - }, - { - label: 'Use native PiP', - type: 'checkbox', - checked: options.useNativePiP, - click(item) { - setOptions({ useNativePiP: item.checked }); + { + label: 'Use native PiP', + type: 'checkbox', + checked: config.useNativePiP, + click(item) { + setConfig({ useNativePiP: item.checked }); + }, }, - }, -]; + ]; +}; diff --git a/src/plugins/picture-in-picture/renderer.ts b/src/plugins/picture-in-picture/renderer.ts index d75bf900..853dcb8e 100644 --- a/src/plugins/picture-in-picture/renderer.ts +++ b/src/plugins/picture-in-picture/renderer.ts @@ -3,13 +3,12 @@ import keyEventAreEqual from 'keyboardevents-areequal'; 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 type { ConfigType } from '../../config/dynamic'; - -type PiPOptions = ConfigType<'picture-in-picture'>; +import type { PictureInPicturePluginConfig } from './index'; +import type { RendererContext } from '@/types/contexts'; function $(selector: string) { return document.querySelector(selector); @@ -135,36 +134,13 @@ const listenForToggle = () => { }); }; -function observeMenu(options: PiPOptions) { - useNativePiP = options.useNativePiP; - document.addEventListener( - 'apiLoaded', - () => { - listenForToggle(); +export const onRendererLoad = async ({ getConfig }: RendererContext) => { + const config = await getConfig(); - cloneButton('.player-minimize-button')?.addEventListener('click', async () => { - await togglePictureInPicture(); - setTimeout(() => $('#player')?.click()); - }); + useNativePiP = config.useNativePiP; - // 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, - }); - }, - { once: true, passive: true }, - ); -} - -export default (options: PiPOptions) => { - observeMenu(options); - - if (options.hotkey) { - const hotkeyEvent = toKeyEvent(options.hotkey); + if (config.hotkey) { + const hotkeyEvent = toKeyEvent(config.hotkey); window.addEventListener('keydown', (event) => { if ( 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(() => $('#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, + }); +}; diff --git a/src/plugins/playback-speed/index.ts b/src/plugins/playback-speed/index.ts new file mode 100644 index 00000000..bcc080e6 --- /dev/null +++ b/src/plugins/playback-speed/index.ts @@ -0,0 +1,14 @@ +import { createPlugin } from '@/utils'; +import { onPlayerApiReady, onUnload } from './renderer'; + +export default createPlugin({ + name: 'Playback Speed', + restartNeeded: false, + config: { + enabled: false, + }, + renderer: { + stop: onUnload, + onPlayerApiReady, + } +}); diff --git a/src/plugins/playback-speed/renderer.ts b/src/plugins/playback-speed/renderer.ts index b105a589..cf079e78 100644 --- a/src/plugins/playback-speed/renderer.ts +++ b/src/plugins/playback-speed/renderer.ts @@ -1,13 +1,9 @@ import sliderHTML from './templates/slider.html?raw'; -import { getSongMenu } from '../../providers/dom-elements'; +import { getSongMenu } from '@/providers/dom-elements'; +import { singleton } from '@/providers/decorators'; + import { ElementFromHtml } from '../utils/renderer'; -import { singleton } from '../../providers/decorators'; - - -function $(selector: string) { - return document.querySelector(selector); -} const slider = ElementFromHtml(sliderHTML); @@ -19,12 +15,12 @@ const MAX_PLAYBACK_SPEED = 16; let playbackSpeed = 1; const updatePlayBackSpeed = () => { - const videoElement = $('video'); + const videoElement = document.querySelector('video'); if (videoElement) { videoElement.playbackRate = playbackSpeed; } - const playbackSpeedElement = $('#playback-speed-value'); + const playbackSpeedElement = document.querySelector('#playback-speed-value'); if (playbackSpeedElement) { playbackSpeedElement.innerHTML = String(playbackSpeed); } @@ -32,15 +28,17 @@ const updatePlayBackSpeed = () => { let menu: Element | null = null; -const setupSliderListener = singleton(() => { - $('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => { - playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED; - if (isNaN(playbackSpeed)) { - playbackSpeed = 1; - } +const immediateValueChangedListener = (e: Event) => { + playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED; + if (isNaN(playbackSpeed)) { + playbackSpeed = 1; + } - updatePlayBackSpeed(); - }); + updatePlayBackSpeed(); +}; + +const setupSliderListener = singleton(() => { + document.querySelector('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener); }); const observePopupContainer = () => { @@ -60,7 +58,7 @@ const observePopupContainer = () => { } }); - const popupContainer = $('ytmusic-popup-container'); + const popupContainer = document.querySelector('ytmusic-popup-container'); if (popupContainer) { observer.observe(popupContainer, { childList: true, @@ -70,33 +68,35 @@ const observePopupContainer = () => { }; const observeVideo = () => { - const video = $('video'); + const video = document.querySelector('video'); if (video) { video.addEventListener('ratechange', forcePlaybackRate); video.addEventListener('srcChanged', forcePlaybackRate); } }; +const wheelEventListener = (e: WheelEvent) => { + e.preventDefault(); + if (isNaN(playbackSpeed)) { + playbackSpeed = 1; + } + + // E.deltaY < 0 means wheel-up + playbackSpeed = roundToTwo(e.deltaY < 0 + ? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED) + : Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED), + ); + + updatePlayBackSpeed(); + // Update slider position + const playbackSpeedSilder = document.querySelector('#playback-speed-slider'); + if (playbackSpeedSilder) { + playbackSpeedSilder.value = playbackSpeed; + } +}; + const setupWheelListener = () => { - slider.addEventListener('wheel', (e) => { - e.preventDefault(); - if (isNaN(playbackSpeed)) { - playbackSpeed = 1; - } - - // E.deltaY < 0 means wheel-up - playbackSpeed = roundToTwo(e.deltaY < 0 - ? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED) - : Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED), - ); - - updatePlayBackSpeed(); - // Update slider position - const playbackSpeedSilder = $('#playback-speed-slider'); - if (playbackSpeedSilder) { - playbackSpeedSilder.value = playbackSpeed; - } - }); + slider.addEventListener('wheel', wheelEventListener); }; function forcePlaybackRate(e: Event) { @@ -108,10 +108,19 @@ function forcePlaybackRate(e: Event) { } } -export default () => { - document.addEventListener('apiLoaded', () => { - observePopupContainer(); - observeVideo(); - setupWheelListener(); - }, { once: true, passive: true }); +export const onPlayerApiReady = () => { + observePopupContainer(); + observeVideo(); + setupWheelListener(); +}; + +export const onUnload = () => { + const video = document.querySelector('video'); + if (video) { + video.removeEventListener('ratechange', forcePlaybackRate); + video.removeEventListener('srcChanged', forcePlaybackRate); + } + slider.removeEventListener('wheel', wheelEventListener); + getSongMenu()?.removeChild(slider); + document.querySelector('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener); }; diff --git a/src/plugins/precise-volume/index.ts b/src/plugins/precise-volume/index.ts new file mode 100644 index 00000000..fa55e039 --- /dev/null +++ b/src/plugins/precise-volume/index.ts @@ -0,0 +1,138 @@ +import { globalShortcut, MenuItem } from 'electron'; +import prompt, { KeybindOptions } from 'custom-electron-prompt'; + +import hudStyle from './volume-hud.css?inline'; +import { createPlugin } from '@/utils'; + +import promptOptions from '@/providers/prompt-options'; +import { overrideListener } from './override'; +import { onConfigChange, onPlayerApiReady } from './renderer'; + +export type PreciseVolumePluginConfig = { + enabled: boolean; + steps: number; + arrowsShortcut: boolean; + globalShortcuts: { + volumeUp: string; + volumeDown: string; + }; + savedVolume: number | undefined; +}; + +export default createPlugin({ + name: 'Precise Volume', + restartNeeded: true, + config: { + enabled: false, + steps: 1, // Percentage of volume to change + arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts + globalShortcuts: { + volumeUp: '', + volumeDown: '', + }, + savedVolume: undefined, // Plugin save volume between session here + } as PreciseVolumePluginConfig, + stylesheets: [hudStyle], + menu: async ({ setConfig, getConfig, window }) => { + const config = await getConfig(); + + function changeOptions(changedOptions: Partial, options: PreciseVolumePluginConfig) { + for (const option in changedOptions) { + // HACK: Weird TypeScript error + (options as Record)[option] = (changedOptions as Record)[option]; + } + + setConfig(options); + } + + // Helper function for globalShortcuts prompt + const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined }); + + async function promptVolumeSteps(options: PreciseVolumePluginConfig) { + const output = await prompt({ + title: 'Volume Steps', + label: 'Choose Volume Increase/Decrease Steps', + value: options.steps || 1, + type: 'counter', + counterOptions: { minimum: 0, maximum: 100, multiFire: true }, + width: 380, + ...promptOptions(), + }, window); + + if (output || output === 0) { // 0 is somewhat valid + changeOptions({ steps: output }, options); + } + } + + async function promptGlobalShortcuts(options: PreciseVolumePluginConfig, item: MenuItem) { + const output = await prompt({ + title: 'Global Volume Keybinds', + label: 'Choose Global Volume Keybinds:', + type: 'keybind', + keybindOptions: [ + kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp), + kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown), + ], + ...promptOptions(), + }, window); + + if (output) { + const newGlobalShortcuts: { + volumeUp: string; + volumeDown: string; + } = { volumeUp: '', volumeDown: '' }; + for (const { value, accelerator } of output) { + newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator; + } + + changeOptions({ globalShortcuts: newGlobalShortcuts }, options); + + item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown); + } else { + // Reset checkbox if prompt was canceled + item.checked = !item.checked; + } + } + + return [ + { + label: 'Local Arrowkeys Controls', + type: 'checkbox', + checked: Boolean(config.arrowsShortcut), + click(item) { + changeOptions({ arrowsShortcut: item.checked }, config); + }, + }, + { + label: 'Global Hotkeys', + type: 'checkbox', + checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown), + click: (item) => promptGlobalShortcuts(config, item), + }, + { + label: 'Set Custom Volume Steps', + click: () => promptVolumeSteps(config), + }, + ]; + }, + + async backend({ getConfig, ipc }) { + const config = await getConfig(); + + if (config.globalShortcuts?.volumeUp) { + globalShortcut.register(config.globalShortcuts.volumeUp, () => ipc.send('changeVolume', true)); + } + + if (config.globalShortcuts?.volumeDown) { + globalShortcut.register(config.globalShortcuts.volumeDown, () => ipc.send('changeVolume', false)); + } + }, + + renderer: { + start() { + overrideListener(); + }, + onPlayerApiReady, + onConfigChange, + } +}); diff --git a/src/plugins/precise-volume/main.ts b/src/plugins/precise-volume/main.ts deleted file mode 100644 index e161f4f2..00000000 --- a/src/plugins/precise-volume/main.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { globalShortcut, BrowserWindow } from 'electron'; - -import volumeHudStyle from './volume-hud.css'; - -import { injectCSS } from '../utils/main'; - -import type { ConfigType } from '../../config/dynamic'; - -/* -This is used to determine if plugin is actually active -(not if it's only enabled in options) -*/ -let isEnabled = false; - -export const enabled = () => isEnabled; - -export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => { - isEnabled = true; - injectCSS(win.webContents, volumeHudStyle); - - if (options.globalShortcuts?.volumeUp) { - globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true)); - } - - if (options.globalShortcuts?.volumeDown) { - globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false)); - } -}; diff --git a/src/plugins/precise-volume/menu.ts b/src/plugins/precise-volume/menu.ts deleted file mode 100644 index fb0ec9c0..00000000 --- a/src/plugins/precise-volume/menu.ts +++ /dev/null @@ -1,94 +0,0 @@ -import prompt, { KeybindOptions } from 'custom-electron-prompt'; - -import { BrowserWindow, MenuItem } from 'electron'; - -import { enabled } from './main'; - -import { setMenuOptions } from '../../config/plugins'; -import promptOptions from '../../providers/prompt-options'; -import { MenuTemplate } from '../../menu'; - -import type { ConfigType } from '../../config/dynamic'; - -function changeOptions(changedOptions: Partial>, options: ConfigType<'precise-volume'>, win: BrowserWindow) { - for (const option in changedOptions) { - // HACK: Weird TypeScript error - (options as Record)[option] = (changedOptions as Record)[option]; - } - // Dynamically change setting if plugin is enabled - if (enabled()) { - win.webContents.send('setOptions', changedOptions); - } else { // Fallback to usual method if disabled - setMenuOptions('precise-volume', options); - } -} - -export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): MenuTemplate => [ - { - label: 'Local Arrowkeys Controls', - type: 'checkbox', - checked: Boolean(options.arrowsShortcut), - click(item) { - changeOptions({ arrowsShortcut: item.checked }, options, win); - }, - }, - { - label: 'Global Hotkeys', - type: 'checkbox', - checked: Boolean(options.globalShortcuts?.volumeUp ?? options.globalShortcuts?.volumeDown), - click: (item) => promptGlobalShortcuts(win, options, item), - }, - { - label: 'Set Custom Volume Steps', - click: () => promptVolumeSteps(win, options), - }, -]; - -// Helper function for globalShortcuts prompt -const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined }); - -async function promptVolumeSteps(win: BrowserWindow, options: ConfigType<'precise-volume'>) { - const output = await prompt({ - title: 'Volume Steps', - label: 'Choose Volume Increase/Decrease Steps', - value: options.steps || 1, - type: 'counter', - counterOptions: { minimum: 0, maximum: 100, multiFire: true }, - width: 380, - ...promptOptions(), - }, win); - - if (output || output === 0) { // 0 is somewhat valid - changeOptions({ steps: output }, options, win); - } -} - -async function promptGlobalShortcuts(win: BrowserWindow, options: ConfigType<'precise-volume'>, item: MenuItem) { - const output = await prompt({ - title: 'Global Volume Keybinds', - label: 'Choose Global Volume Keybinds:', - type: 'keybind', - keybindOptions: [ - kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp), - kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown), - ], - ...promptOptions(), - }, win); - - if (output) { - const newGlobalShortcuts: { - volumeUp: string; - volumeDown: string; - } = { volumeUp: '', volumeDown: '' }; - for (const { value, accelerator } of output) { - newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator; - } - - changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win); - - item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown); - } else { - // Reset checkbox if prompt was canceled - item.checked = !item.checked; - } -} diff --git a/src/plugins/precise-volume/override.ts b/src/plugins/precise-volume/override.ts index bffef1e8..0b001824 100644 --- a/src/plugins/precise-volume/override.ts +++ b/src/plugins/precise-volume/override.ts @@ -1,5 +1,4 @@ /* what */ -/* eslint-disable @typescript-eslint/ban-ts-comment */ const ignored = { id: ['volume-slider', 'expand-volume-slider'], @@ -9,7 +8,8 @@ const ignored = { function overrideAddEventListener() { // YO WHAT ARE YOU DOING NOW?!?! // Save native addEventListener - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error - We know what we're doing // eslint-disable-next-line @typescript-eslint/unbound-method Element.prototype._addEventListener = Element.prototype.addEventListener; // Override addEventListener to Ignore specific events in volume-slider diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index 0cf0d13c..0c9bbd22 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -1,33 +1,15 @@ -import { overrideListener } from './override'; +import { type PreciseVolumePluginConfig } from './index'; -import { debounce } from '../../providers/decorators'; +import { debounce } from '@/providers/decorators'; -import type { YoutubePlayer } from '../../types/youtube-player'; -import type { ConfigType } from '../../config/dynamic'; +import type { RendererContext } from '@/types/contexts'; +import type { YoutubePlayer } from '@/types/youtube-player'; function $(selector: string) { return document.querySelector(selector); } let api: YoutubePlayer; -let options: ConfigType<'precise-volume'>; - -export default (_options: ConfigType<'precise-volume'>) => { - overrideListener(); - - options = _options; - document.addEventListener('apiLoaded', (e) => { - api = e.detail; - window.ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease)); - window.ipcRenderer.on('setVolume', (_, value: number) => setVolume(value)); - firstRun(); - }, { once: true, passive: true }); -}; - -// Without this function it would rewrite config 20 time when volume change by 20 -const writeOptions = debounce(() => { - window.mainConfig.plugins.setOptions('precise-volume', options); -}, 1000); export const moveVolumeHud = debounce((showVideo: boolean) => { const volumeHud = $('#volumeHud'); @@ -40,231 +22,246 @@ export const moveVolumeHud = debounce((showVideo: boolean) => { : '0'; }, 250); -const hideVolumeHud = debounce((volumeHud: HTMLElement) => { - volumeHud.style.opacity = '0'; -}, 2000); +let options: PreciseVolumePluginConfig; -const hideVolumeSlider = debounce((slider: HTMLElement) => { - slider.classList.remove('on-hover'); -}, 2500); +export const onPlayerApiReady = async (playerApi: YoutubePlayer, context: RendererContext) => { + options = await context.getConfig(); + api = playerApi; -/** Restore saved volume and setup tooltip */ -function firstRun() { - if (typeof options.savedVolume === 'number') { - // Set saved volume as tooltip - setTooltip(options.savedVolume); + // Without this function it would rewrite config 20 time when volume change by 20 + const writeOptions = debounce(() => { + context.setConfig(options); + }, 1000); - if (api.getVolume() !== options.savedVolume) { - setVolume(options.savedVolume); - } - } + const hideVolumeHud = debounce((volumeHud: HTMLElement) => { + volumeHud.style.opacity = '0'; + }, 2000); - setupPlaybar(); + const hideVolumeSlider = debounce((slider: HTMLElement) => { + slider.classList.remove('on-hover'); + }, 2500); - setupLocalArrowShortcuts(); + /** Restore saved volume and setup tooltip */ + function firstRun() { + if (typeof options.savedVolume === 'number') { + // Set saved volume as tooltip + setTooltip(options.savedVolume); - // Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue - const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none'; - injectVolumeHud(noVid); - if (!noVid) { - setupVideoPlayerOnwheel(); - if (!window.mainConfig.plugins.isEnabled('video-toggle')) { - // Video-toggle handles hud positioning on its own - const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'; - $('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode())); - } - } - - // Change options from renderer to keep sync - window.ipcRenderer.on('setOptions', (_event, newOptions = {}) => { - Object.assign(options, newOptions); - window.mainConfig.plugins.setMenuOptions('precise-volume', options); - }); -} - -function injectVolumeHud(noVid: boolean) { - if (noVid) { - const position = 'top: 18px; right: 60px;'; - const mainStyle = 'font-size: xx-large;'; - - $('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML( - 'beforeend', - ``, - ); - } else { - const position = 'top: 10px; left: 10px;'; - const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;'; - - $('#song-video')?.insertAdjacentHTML( - 'afterend', - ``, - ); - } -} - -function showVolumeHud(volume: number) { - const volumeHud = $('#volumeHud'); - if (!volumeHud) { - return; - } - - volumeHud.textContent = `${volume}%`; - volumeHud.style.opacity = '1'; - - hideVolumeHud(volumeHud); -} - -/** Add onwheel event to video player */ -function setupVideoPlayerOnwheel() { - const panel = $('#main-panel'); - if (!panel) return; - - panel.addEventListener('wheel', (event) => { - event.preventDefault(); - // Event.deltaY < 0 means wheel-up - changeVolume(event.deltaY < 0); - }); -} - -function saveVolume(volume: number) { - options.savedVolume = volume; - writeOptions(); -} - -/** Add onwheel event to play bar and also track if play bar is hovered */ -function setupPlaybar() { - const playerbar = $('ytmusic-player-bar'); - if (!playerbar) return; - - playerbar.addEventListener('wheel', (event) => { - event.preventDefault(); - // Event.deltaY < 0 means wheel-up - changeVolume(event.deltaY < 0); - }); - - // Keep track of mouse position for showVolumeSlider() - playerbar.addEventListener('mouseenter', () => { - playerbar.classList.add('on-hover'); - }); - - playerbar.addEventListener('mouseleave', () => { - playerbar.classList.remove('on-hover'); - }); - - setupSliderObserver(); -} - -/** Save volume + Update the volume tooltip when volume-slider is manually changed */ -function setupSliderObserver() { - const sliderObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.target instanceof HTMLInputElement) { - // This checks that volume-slider was manually set - const target = mutation.target; - const targetValueNumeric = Number(target.value); - if (mutation.oldValue !== target.value - && (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) { - // Diff>4 means it was manually set - setTooltip(targetValueNumeric); - saveVolume(targetValueNumeric); - } + if (api.getVolume() !== options.savedVolume) { + setVolume(options.savedVolume); } } - }); - const slider = $('#volume-slider'); - if (!slider) return; + setupPlaybar(); - // Observing only changes in 'value' of volume-slider - sliderObserver.observe(slider, { - attributeFilter: ['value'], - attributeOldValue: true, - }); -} + setupLocalArrowShortcuts(); -function setVolume(value: number) { - api.setVolume(value); - // Save the new volume - saveVolume(value); - - // Change slider position (important) - updateVolumeSlider(); - - // Change tooltips to new value - setTooltip(value); - // Show volume slider - showVolumeSlider(); - // Show volume HUD - showVolumeHud(value); -} - -/** If (toIncrease = false) then volume decrease */ -function changeVolume(toIncrease: boolean) { - // Apply volume change if valid - const steps = Number(options.steps || 1); - setVolume(toIncrease - ? Math.min(api.getVolume() + steps, 100) - : Math.max(api.getVolume() - steps, 0)); -} - -function updateVolumeSlider() { - const savedVolume = options.savedVolume ?? 0; - // Slider value automatically rounds to multiples of 5 - for (const slider of ['#volume-slider', '#expand-volume-slider']) { - const silderElement = $(slider); - if (silderElement) { - silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume); - } - } -} - -function showVolumeSlider() { - const slider = $('#volume-slider'); - if (!slider) return; - - // This class display the volume slider if not in minimized mode - slider.classList.add('on-hover'); - - hideVolumeSlider(slider); -} - -// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small) -const tooltipTargets = [ - '#volume-slider', - 'tp-yt-paper-icon-button.volume', - '#expand-volume-slider', - '#expand-volume', -]; - -function setTooltip(volume: number) { - for (const target of tooltipTargets) { - const tooltipTargetElement = $(target); - if (tooltipTargetElement) { - tooltipTargetElement.title = `${volume}%`; - } - } -} - -function setupLocalArrowShortcuts() { - if (options.arrowsShortcut) { - window.addEventListener('keydown', (event) => { - if ($('ytmusic-search-box')?.opened) { - return; + // Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue + const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none'; + injectVolumeHud(noVid); + if (!noVid) { + setupVideoPlayerOnwheel(); + if (!window.mainConfig.plugins.isEnabled('video-toggle')) { + // Video-toggle handles hud positioning on its own + const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'; + $('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode())); } + } + } - switch (event.code) { - case 'ArrowUp': { - event.preventDefault(); - changeVolume(true); - break; - } + function injectVolumeHud(noVid: boolean) { + if (noVid) { + const position = 'top: 18px; right: 60px;'; + const mainStyle = 'font-size: xx-large;'; - case 'ArrowDown': { - event.preventDefault(); - changeVolume(false); - break; + $('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML( + 'beforeend', + ``, + ); + } else { + const position = 'top: 10px; left: 10px;'; + const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;'; + + $('#song-video')?.insertAdjacentHTML( + 'afterend', + ``, + ); + } + } + + function showVolumeHud(volume: number) { + const volumeHud = $('#volumeHud'); + if (!volumeHud) { + return; + } + + volumeHud.textContent = `${volume}%`; + volumeHud.style.opacity = '1'; + + hideVolumeHud(volumeHud); + } + + /** Add onwheel event to video player */ + function setupVideoPlayerOnwheel() { + const panel = $('#main-panel'); + if (!panel) return; + + panel.addEventListener('wheel', (event) => { + event.preventDefault(); + // Event.deltaY < 0 means wheel-up + changeVolume(event.deltaY < 0); + }); + } + + function saveVolume(volume: number) { + options.savedVolume = volume; + writeOptions(); + } + + /** Add onwheel event to play bar and also track if play bar is hovered */ + function setupPlaybar() { + const playerbar = $('ytmusic-player-bar'); + if (!playerbar) return; + + playerbar.addEventListener('wheel', (event) => { + event.preventDefault(); + // Event.deltaY < 0 means wheel-up + changeVolume(event.deltaY < 0); + }); + + // Keep track of mouse position for showVolumeSlider() + playerbar.addEventListener('mouseenter', () => { + playerbar.classList.add('on-hover'); + }); + + playerbar.addEventListener('mouseleave', () => { + playerbar.classList.remove('on-hover'); + }); + + setupSliderObserver(); + } + + /** Save volume + Update the volume tooltip when volume-slider is manually changed */ + function setupSliderObserver() { + const sliderObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.target instanceof HTMLInputElement) { + // This checks that volume-slider was manually set + const target = mutation.target; + const targetValueNumeric = Number(target.value); + if (mutation.oldValue !== target.value + && (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) { + // Diff>4 means it was manually set + setTooltip(targetValueNumeric); + saveVolume(targetValueNumeric); + } } } }); + + const slider = $('#volume-slider'); + if (!slider) return; + + // Observing only changes in 'value' of volume-slider + sliderObserver.observe(slider, { + attributeFilter: ['value'], + attributeOldValue: true, + }); } -} + + function setVolume(value: number) { + api.setVolume(value); + // Save the new volume + saveVolume(value); + + // Change slider position (important) + updateVolumeSlider(); + + // Change tooltips to new value + setTooltip(value); + // Show volume slider + showVolumeSlider(); + // Show volume HUD + showVolumeHud(value); + } + + /** If (toIncrease = false) then volume decrease */ + function changeVolume(toIncrease: boolean) { + // Apply volume change if valid + const steps = Number(options.steps || 1); + setVolume(toIncrease + ? Math.min(api.getVolume() + steps, 100) + : Math.max(api.getVolume() - steps, 0)); + } + + function updateVolumeSlider() { + const savedVolume = options.savedVolume ?? 0; + // Slider value automatically rounds to multiples of 5 + for (const slider of ['#volume-slider', '#expand-volume-slider']) { + const silderElement = $(slider); + if (silderElement) { + silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume); + } + } + } + + function showVolumeSlider() { + const slider = $('#volume-slider'); + if (!slider) return; + + // This class display the volume slider if not in minimized mode + slider.classList.add('on-hover'); + + hideVolumeSlider(slider); + } + + // Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small) + const tooltipTargets = [ + '#volume-slider', + 'tp-yt-paper-icon-button.volume', + '#expand-volume-slider', + '#expand-volume', + ]; + + function setTooltip(volume: number) { + for (const target of tooltipTargets) { + const tooltipTargetElement = $(target); + if (tooltipTargetElement) { + tooltipTargetElement.title = `${volume}%`; + } + } + } + + function setupLocalArrowShortcuts() { + if (options.arrowsShortcut) { + window.addEventListener('keydown', (event) => { + if ($('ytmusic-search-box')?.opened) { + return; + } + + switch (event.code) { + case 'ArrowUp': { + event.preventDefault(); + changeVolume(true); + break; + } + + case 'ArrowDown': { + event.preventDefault(); + changeVolume(false); + break; + } + } + }); + } + } + + context.ipc.on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); + context.ipc.on('setVolume', (value: number) => setVolume(value)); + + firstRun(); +}; + +export const onConfigChange = (config: PreciseVolumePluginConfig) => { + options = config; +}; diff --git a/src/plugins/quality-changer/index.ts b/src/plugins/quality-changer/index.ts new file mode 100644 index 00000000..f29ffd31 --- /dev/null +++ b/src/plugins/quality-changer/index.ts @@ -0,0 +1,65 @@ +import { dialog } from 'electron'; + +import QualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw'; + +import { createPlugin } from '@/utils'; +import { ElementFromHtml } from '@/plugins/utils/renderer'; + +import type { YoutubePlayer } from '@/types/youtube-player'; + +export default createPlugin({ + name: 'Video Quality Changer', + restartNeeded: false, + config: { + enabled: false, + }, + + backend({ ipc, window }) { + ipc.handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(window, { + type: 'question', + buttons: qualityLabels, + defaultId: currentIndex, + title: 'Choose Video Quality', + message: 'Choose Video Quality:', + detail: `Current Quality: ${qualityLabels[currentIndex]}`, + cancelId: -1, + })); + }, + + renderer: { + qualitySettingsButton: ElementFromHtml(QualitySettingsTemplate), + onPlayerApiReady(api: YoutubePlayer, context) { + const getPlayer = () => document.querySelector('#player'); + const chooseQuality = () => { + setTimeout(() => getPlayer()?.click()); + + const qualityLevels = api.getAvailableQualityLevels(); + + const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); + + (context.ipc.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex) as Promise<{ response: number }>) + .then((promise) => { + if (promise.response === -1) { + return; + } + + const newQuality = qualityLevels[promise.response]; + api.setPlaybackQualityRange(newQuality); + api.setPlaybackQuality(newQuality); + }); + }; + + const setup = () => { + document.querySelector('.top-row-buttons.ytmusic-player')?.prepend(this.qualitySettingsButton); + + this.qualitySettingsButton.addEventListener('click', chooseQuality); + }; + + setup(); + }, + stop() { + document.querySelector('.top-row-buttons.ytmusic-player')?.removeChild(this.qualitySettingsButton); + }, + } +}); + diff --git a/src/plugins/quality-changer/main.ts b/src/plugins/quality-changer/main.ts deleted file mode 100644 index aa6d0b36..00000000 --- a/src/plugins/quality-changer/main.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron'; - -export default (win: BrowserWindow) => { - ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, { - type: 'question', - buttons: qualityLabels, - defaultId: currentIndex, - title: 'Choose Video Quality', - message: 'Choose Video Quality:', - detail: `Current Quality: ${qualityLabels[currentIndex]}`, - cancelId: -1, - })); -}; diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts deleted file mode 100644 index 8f6982e0..00000000 --- a/src/plugins/quality-changer/renderer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw'; - -import { ElementFromHtml } from '../utils/renderer'; -import { YoutubePlayer } from '../../types/youtube-player'; - -function $(selector: string): HTMLElement | null { - return document.querySelector(selector); -} - -const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate); - -function setup(event: CustomEvent) { - const api = event.detail; - - $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); - - qualitySettingsButton.addEventListener('click', function chooseQuality() { - setTimeout(() => $('#player')?.click()); - - const qualityLevels = api.getAvailableQualityLevels(); - - const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); - - window.ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => { - if (promise.response === -1) { - return; - } - - const newQuality = qualityLevels[promise.response]; - api.setPlaybackQualityRange(newQuality); - api.setPlaybackQuality(newQuality); - }); - }); -} - -export default () => { - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); -}; diff --git a/src/plugins/shortcuts/index.ts b/src/plugins/shortcuts/index.ts new file mode 100644 index 00000000..c1436566 --- /dev/null +++ b/src/plugins/shortcuts/index.ts @@ -0,0 +1,37 @@ +import { createPlugin } from '@/utils'; +import { onMainLoad } from './main'; +import { onMenu } from './menu'; + +export type ShortcutMappingType = { + previous: string; + playPause: string; + next: string; +}; +export type ShortcutsPluginConfig = { + enabled: boolean; + overrideMediaKeys: boolean; + global: ShortcutMappingType; + local: ShortcutMappingType; +} + +export default createPlugin({ + name: 'Shortcuts (& MPRIS)', + restartNeeded: true, + config: { + enabled: false, + overrideMediaKeys: false, + global: { + previous: '', + playPause: '', + next: '', + }, + local: { + previous: '', + playPause: '', + next: '', + }, + } as ShortcutsPluginConfig, + menu: onMenu, + + backend: onMainLoad, +}); diff --git a/src/plugins/shortcuts/main.ts b/src/plugins/shortcuts/main.ts index 8447092f..18480062 100644 --- a/src/plugins/shortcuts/main.ts +++ b/src/plugins/shortcuts/main.ts @@ -1,12 +1,14 @@ import { BrowserWindow, globalShortcut } from 'electron'; import is from 'electron-is'; -import electronLocalshortcut from 'electron-localshortcut'; +import { register as registerElectronLocalShortcut } from 'electron-localshortcut'; import registerMPRIS from './mpris'; +import getSongControls from '@/providers/song-controls'; -import getSongControls from '../../providers/song-controls'; +import type { ShortcutMappingType, ShortcutsPluginConfig } from './index'; + +import type { BackendContext } from '@/types/contexts'; -import type { ConfigType } from '../../config/dynamic'; function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) { globalShortcut.register(shortcut, () => { @@ -15,55 +17,58 @@ function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: st } function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) { - electronLocalshortcut.register(win, shortcut, () => { + registerElectronLocalShortcut(win, shortcut, () => { action(win.webContents); }); } -function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) { - const songControls = getSongControls(win); +export const onMainLoad = async ({ getConfig, window }: BackendContext) => { + const config = await getConfig(); + + const songControls = getSongControls(window); const { playPause, next, previous, search } = songControls; - if (options.overrideMediaKeys) { - _registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); - _registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); - _registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); + if (config.overrideMediaKeys) { + _registerGlobalShortcut(window.webContents, 'MediaPlayPause', playPause); + _registerGlobalShortcut(window.webContents, 'MediaNextTrack', next); + _registerGlobalShortcut(window.webContents, 'MediaPreviousTrack', previous); } - _registerLocalShortcut(win, 'CommandOrControl+F', search); - _registerLocalShortcut(win, 'CommandOrControl+L', search); + _registerLocalShortcut(window, 'CommandOrControl+F', search); + _registerLocalShortcut(window, 'CommandOrControl+L', search); if (is.linux()) { - registerMPRIS(win); + registerMPRIS(window); } - const { global, local } = options; + const { global, local } = config; const shortcutOptions = { global, local }; for (const optionType in shortcutOptions) { registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType); } - function registerAllShortcuts(container: Record, type: string) { - for (const action in container) { + function registerAllShortcuts(container: ShortcutMappingType, type: string) { + for (const _action in container) { + // HACK: _action is detected as string, but it's actually a key of ShortcutMappingType + const action = _action as keyof ShortcutMappingType; + if (!container[action]) { continue; // Action accelerator is empty } console.debug(`Registering ${type} shortcut`, container[action], ':', action); - const actionCallback: () => void = songControls[action as keyof typeof songControls]; + const actionCallback: () => void = songControls[action]; if (typeof actionCallback !== 'function') { console.warn('Invalid action', action); continue; } if (type === 'global') { - _registerGlobalShortcut(win.webContents, container[action], actionCallback); + _registerGlobalShortcut(window.webContents, container[action], actionCallback); } else { // Type === "local" - _registerLocalShortcut(win, local[action], actionCallback); + _registerLocalShortcut(window, local[action], actionCallback); } } } -} - -export default registerShortcuts; +}; diff --git a/src/plugins/shortcuts/menu.ts b/src/plugins/shortcuts/menu.ts index 68854fe7..47d5a931 100644 --- a/src/plugins/shortcuts/menu.ts +++ b/src/plugins/shortcuts/menu.ts @@ -1,67 +1,56 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt'; -import { BrowserWindow } from 'electron'; +import promptOptions from '@/providers/prompt-options'; -import { setMenuOptions } from '../../config/plugins'; +import type { ShortcutsPluginConfig } from './index'; +import type { BrowserWindow } from 'electron'; +import type { MenuContext } from '@/types/contexts'; +import type { MenuTemplate } from '@/menu'; +export const onMenu = async ({ window, getConfig, setConfig }: MenuContext): Promise => { + const config = await getConfig(); -import promptOptions from '../../providers/prompt-options'; -import { MenuTemplate } from '../../menu'; + /** + * Helper function for keybind prompt + */ + const kb = (label_: string, value_: string, default_?: string): KeybindOptions => ({ value: value_, label: label_, default: default_ }); -import type { ConfigType } from '../../config/dynamic'; + async function promptKeybind(config: ShortcutsPluginConfig, win: BrowserWindow) { + const output = await prompt({ + title: 'Global Keybinds', + label: 'Choose Global Keybinds for Songs Control:', + type: 'keybind', + keybindOptions: [ // If default=undefined then no default is used + kb('Previous', 'previous', config.global?.previous), + kb('Play / Pause', 'playPause', config.global?.playPause), + kb('Next', 'next', config.global?.next), + ], + height: 270, + ...promptOptions(), + }, win); -export default (win: BrowserWindow, options: ConfigType<'shortcuts'>): MenuTemplate => [ - { - label: 'Set Global Song Controls', - click: () => promptKeybind(options, win), - }, - { - label: 'Override MediaKeys', - type: 'checkbox', - checked: options.overrideMediaKeys, - click: (item) => setOption(options, 'overrideMediaKeys', item.checked), - }, -]; + if (output) { + const newConfig = { ...config }; -function setOption = keyof ConfigType<'shortcuts'>>( - options: ConfigType<'shortcuts'>, - key: Key | null = null, - newValue: ConfigType<'shortcuts'>[Key] | null = null, -) { - if (key && newValue !== null) { - options[key] = newValue; + for (const { value, accelerator } of output) { + newConfig.global[value as keyof ShortcutsPluginConfig['global']] = accelerator; + } + + setConfig(config); + } + // Else -> pressed cancel } - setMenuOptions('shortcuts', options); -} - -// Helper function for keybind prompt -const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ value: value_, label: label_, default: default_ }); - -async function promptKeybind(options: ConfigType<'shortcuts'>, win: BrowserWindow) { - const output = await prompt({ - title: 'Global Keybinds', - label: 'Choose Global Keybinds for Songs Control:', - type: 'keybind', - keybindOptions: [ // If default=undefined then no default is used - kb('Previous', 'previous', options.global?.previous), - kb('Play / Pause', 'playPause', options.global?.playPause), - kb('Next', 'next', options.global?.next), - ], - height: 270, - ...promptOptions(), - }, win); - - if (output) { - if (!options.global) { - options.global = {}; - } - - for (const { value, accelerator } of output) { - options.global[value] = accelerator; - } - - setOption(options); - } - // Else -> pressed cancel -} + return [ + { + label: 'Set Global Song Controls', + click: () => promptKeybind(config, window), + }, + { + label: 'Override MediaKeys', + type: 'checkbox', + checked: config.overrideMediaKeys, + click: (item) => setConfig({ overrideMediaKeys: item.checked }), + }, + ]; +}; diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 46109a77..115d2d74 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain } from 'electron'; import mpris, { Track } from '@jellybrick/mpris-service'; -import registerCallback from '../../providers/song-info'; -import getSongControls from '../../providers/song-controls'; -import config from '../../config'; +import registerCallback from '@/providers/song-info'; +import getSongControls from '@/providers/song-controls'; +import config from '@/config'; function setupMPRIS() { const instance = new mpris({ @@ -32,7 +32,7 @@ function registerMPRIS(win: BrowserWindow) { const player = setupMPRIS(); - ipcMain.on('apiLoaded', () => { + ipcMain.on('ytmd:player-api-loaded', () => { win.webContents.send('setupSeekedListener', 'mpris'); win.webContents.send('setupTimeChangedListener', 'mpris'); win.webContents.send('setupRepeatChangedListener', 'mpris'); diff --git a/src/plugins/skip-silences/index.ts b/src/plugins/skip-silences/index.ts new file mode 100644 index 00000000..f0499ac5 --- /dev/null +++ b/src/plugins/skip-silences/index.ts @@ -0,0 +1,20 @@ +import { createPlugin } from '@/utils'; +import { onRendererLoad, onRendererUnload } from './renderer'; + +export type SkipSilencesPluginConfig = { + enabled: boolean; + onlySkipBeginning: boolean; +}; + +export default createPlugin({ + name: 'Skip Silences', + restartNeeded: true, + config: { + enabled: false, + onlySkipBeginning: false, + } as SkipSilencesPluginConfig, + renderer: { + start: onRendererLoad, + stop: onRendererUnload, + } +}); diff --git a/src/plugins/skip-silences/renderer.ts b/src/plugins/skip-silences/renderer.ts index b1db4199..c8c74061 100644 --- a/src/plugins/skip-silences/renderer.ts +++ b/src/plugins/skip-silences/renderer.ts @@ -1,112 +1,20 @@ -import type { ConfigType } from '../../config/dynamic'; +import type { RendererContext } from '@/types/contexts'; +import type { SkipSilencesPluginConfig } from './index'; -type SkipSilencesOptions = ConfigType<'skip-silences'>; +let config: SkipSilencesPluginConfig; -export default (options: SkipSilencesOptions) => { - let isSilent = false; - let hasAudioStarted = false; +let isSilent = false; +let hasAudioStarted = false; - const smoothing = 0.1; - const threshold = -100; // DB (-100 = absolute silence, 0 = loudest) - const interval = 2; // Ms - const history = 10; - const speakingHistory = Array.from({ length: history }).fill(0) as number[]; +const smoothing = 0.1; +const threshold = -100; // DB (-100 = absolute silence, 0 = loudest) +const interval = 2; // Ms +const history = 10; +const speakingHistory = Array.from({ length: history }).fill(0) as number[]; - document.addEventListener( - 'audioCanPlay', - (e) => { - const video = document.querySelector('video'); - const { audioContext } = e.detail; - const sourceNode = e.detail.audioSource; +let playOrSeekHandler: (() => void) | undefined; - // Use an audio analyser similar to Hark - // https://github.com/otalk/hark/blob/master/hark.bundle.js - const analyser = audioContext.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = smoothing; - const fftBins = new Float32Array(analyser.frequencyBinCount); - - sourceNode.connect(analyser); - analyser.connect(audioContext.destination); - - const looper = () => { - setTimeout(() => { - const currentVolume = getMaxVolume(analyser, fftBins); - - let history = 0; - if (currentVolume > threshold && isSilent) { - // Trigger quickly, short history - for ( - let i = speakingHistory.length - 3; - i < speakingHistory.length; - i++ - ) { - history += speakingHistory[i]; - } - - if (history >= 2) { - // Not silent - isSilent = false; - hasAudioStarted = true; - } - } else if (currentVolume < threshold && !isSilent) { - for (const element of speakingHistory) { - history += element; - } - - if (history == 0 // Silent - - && !( - video && ( - video.paused - || video.seeking - || video.ended - || video.muted - || video.volume === 0 - ) - ) - ) { - isSilent = true; - skipSilence(); - } - } - - speakingHistory.shift(); - speakingHistory.push(Number(currentVolume > threshold)); - - looper(); - }, interval); - }; - - looper(); - - const skipSilence = () => { - if (options.onlySkipBeginning && hasAudioStarted) { - return; - } - - if (isSilent && video && !video.paused) { - video.currentTime += 0.2; // In s - } - }; - - video?.addEventListener('play', () => { - hasAudioStarted = false; - skipSilence(); - }); - - video?.addEventListener('seeked', () => { - hasAudioStarted = false; - skipSilence(); - }); - }, - { - passive: true, - }, - ); -}; - -function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) { +const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => { let maxVolume = Number.NEGATIVE_INFINITY; analyser.getFloatFrequencyData(fftBins); @@ -117,4 +25,114 @@ function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) { } return maxVolume; -} +}; + +const audioCanPlayListener = (e: CustomEvent) => { + const video = document.querySelector('video'); + const { audioContext } = e.detail; + const sourceNode = e.detail.audioSource; + + // Use an audio analyser similar to Hark + // https://github.com/otalk/hark/blob/master/hark.bundle.js + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = smoothing; + const fftBins = new Float32Array(analyser.frequencyBinCount); + + sourceNode.connect(analyser); + analyser.connect(audioContext.destination); + + const looper = () => { + setTimeout(() => { + const currentVolume = getMaxVolume(analyser, fftBins); + + let history = 0; + if (currentVolume > threshold && isSilent) { + // Trigger quickly, short history + for ( + let i = speakingHistory.length - 3; + i < speakingHistory.length; + i++ + ) { + history += speakingHistory[i]; + } + + if (history >= 2) { + // Not silent + isSilent = false; + hasAudioStarted = true; + } + } else if (currentVolume < threshold && !isSilent) { + for (const element of speakingHistory) { + history += element; + } + + if (history == 0 // Silent + + && !( + video && ( + video.paused + || video.seeking + || video.ended + || video.muted + || video.volume === 0 + ) + ) + ) { + isSilent = true; + skipSilence(); + } + } + + speakingHistory.shift(); + speakingHistory.push(Number(currentVolume > threshold)); + + looper(); + }, interval); + }; + + looper(); + + const skipSilence = () => { + if (config.onlySkipBeginning && hasAudioStarted) { + return; + } + + if (isSilent && video && !video.paused) { + video.currentTime += 0.2; // In s + } + }; + + playOrSeekHandler = () => { + hasAudioStarted = false; + skipSilence(); + }; + + video?.addEventListener('play', playOrSeekHandler); + video?.addEventListener('seeked', playOrSeekHandler); +}; + +export const onRendererLoad = async ({ getConfig }: RendererContext) => { + config = await getConfig(); + + document.addEventListener( + 'audioCanPlay', + audioCanPlayListener, + { + passive: true, + }, + ); +}; + +export const onRendererUnload = () => { + document.removeEventListener( + 'audioCanPlay', + audioCanPlayListener, + ); + + if (playOrSeekHandler) { + const video = document.querySelector('video'); + video?.removeEventListener('play', playOrSeekHandler); + video?.removeEventListener('seeked', playOrSeekHandler); + } +}; diff --git a/src/plugins/sponsorblock/index.ts b/src/plugins/sponsorblock/index.ts new file mode 100644 index 00000000..4df7d7ac --- /dev/null +++ b/src/plugins/sponsorblock/index.ts @@ -0,0 +1,112 @@ +import is from 'electron-is'; + +import { createPlugin } from '@/utils'; + +import { sortSegments } from './segments'; + +import type { GetPlayerResponse } from '@/types/get-player-response'; +import type { Segment, SkipSegment } from './types'; + +export type SponsorBlockPluginConfig = { + enabled: boolean; + apiURL: string; + categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[]; +}; + +let currentSegments: Segment[] = []; + +export default createPlugin({ + name: 'SponsorBlock', + restartNeeded: true, + config: { + enabled: false, + apiURL: 'https://sponsor.ajay.app', + categories: [ + 'sponsor', + 'intro', + 'outro', + 'interaction', + 'selfpromo', + 'music_offtopic', + ], + } as SponsorBlockPluginConfig, + async backend({ getConfig, ipc }) { + const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => { + const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify( + categories, + )}`; + try { + const resp = await fetch(sponsorBlockURL, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + }); + if (resp.status !== 200) { + return []; + } + + const segments = await resp.json() as SkipSegment[]; + return sortSegments( + segments.map((submission) => submission.segment), + ); + } catch (error) { + if (is.dev()) { + console.log('error on sponsorblock request:', error); + } + + return []; + } + }; + + const config = await getConfig(); + + const { apiURL, categories } = config; + + ipc.on('video-src-changed', async (data: GetPlayerResponse) => { + const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId); + ipc.send('sponsorblock-skip', segments); + }); + }, + renderer: { + timeUpdateListener: (e: Event) => { + if (e.target instanceof HTMLVideoElement) { + const target = e.target; + + for (const segment of currentSegments) { + if ( + target.currentTime >= segment[0] + && target.currentTime < segment[1] + ) { + target.currentTime = segment[1]; + if (window.electronIs.dev()) { + console.log('SponsorBlock: skipping segment', segment); + } + } + } + } + }, + resetSegments: () => currentSegments = [], + start({ ipc }) { + ipc.on('sponsorblock-skip', (segments: Segment[]) => { + currentSegments = segments; + }); + }, + onPlayerApiReady() { + const video = document.querySelector('video'); + if (!video) return; + + video.addEventListener('timeupdate', this.timeUpdateListener); + // Reset segments on song end + video.addEventListener('emptied', this.resetSegments); + }, + stop() { + const video = document.querySelector('video'); + if (!video) return; + + video.removeEventListener('timeupdate', this.timeUpdateListener); + video.removeEventListener('emptied', this.resetSegments); + } + } +}); diff --git a/src/plugins/sponsorblock/main.ts b/src/plugins/sponsorblock/main.ts deleted file mode 100644 index ae9a4441..00000000 --- a/src/plugins/sponsorblock/main.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { BrowserWindow, ipcMain } from 'electron'; -import is from 'electron-is'; - -import { sortSegments } from './segments'; - -import { SkipSegment } from './types'; - -import defaultConfig from '../../config/defaults'; - -import type { GetPlayerResponse } from '../../types/get-player-response'; -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => { - const { apiURL, categories } = { - ...defaultConfig.plugins.sponsorblock, - ...options, - }; - - ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => { - const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId); - win.webContents.send('sponsorblock-skip', segments); - }); -}; - -const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => { - const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify( - categories, - )}`; - try { - const resp = await fetch(sponsorBlockURL, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - redirect: 'follow', - }); - if (resp.status !== 200) { - return []; - } - - const segments = await resp.json() as SkipSegment[]; - return sortSegments( - segments.map((submission) => submission.segment), - ); - } catch (error) { - if (is.dev()) { - console.log('error on sponsorblock request:', error); - } - - return []; - } -}; diff --git a/src/plugins/sponsorblock/renderer.ts b/src/plugins/sponsorblock/renderer.ts deleted file mode 100644 index 022ff950..00000000 --- a/src/plugins/sponsorblock/renderer.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Segment } from './types'; - -let currentSegments: Segment[] = []; - -export default () => { - window.ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => { - currentSegments = segments; - }); - - document.addEventListener('apiLoaded', () => { - const video = document.querySelector('video'); - if (!video) return; - - video.addEventListener('timeupdate', (e) => { - if (e.target instanceof HTMLVideoElement) { - const target = e.target; - - for (const segment of currentSegments) { - if ( - target.currentTime >= segment[0] - && target.currentTime < segment[1] - ) { - target.currentTime = segment[1]; - if (window.electronIs.dev()) { - console.log('SponsorBlock: skipping segment', segment); - } - } - } - } - }); - // Reset segments on song end - video.addEventListener('emptied', () => currentSegments = []); - }, { once: true, passive: true }); -}; diff --git a/src/plugins/taskbar-mediacontrol/index.ts b/src/plugins/taskbar-mediacontrol/index.ts new file mode 100644 index 00000000..fc4df2ac --- /dev/null +++ b/src/plugins/taskbar-mediacontrol/index.ts @@ -0,0 +1,84 @@ +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 { nativeImage } from 'electron'; + +import { createPlugin } from '@/utils'; +import getSongControls from '@/providers/song-controls'; +import registerCallback, { type SongInfo } from '@/providers/song-info'; +import { mediaIcons } from '@/types/media-icons'; + +export default createPlugin({ + name: 'Taskbar Media Control', + restartNeeded: true, + config: { + enabled: false, + }, + + backend({ window }) { + let currentSongInfo: SongInfo; + + const { playPause, next, previous } = getSongControls(window); + + const setThumbar = (songInfo: SongInfo) => { + // Wait for song to start before setting thumbar + if (!songInfo?.title) { + return; + } + + // Win32 require full rewrite of components + window.setThumbarButtons([ + { + tooltip: 'Previous', + icon: nativeImage.createFromPath(get('previous')), + click() { + previous(); + }, + }, { + tooltip: 'Play/Pause', + // Update icon based on play state + icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')), + click() { + playPause(); + }, + }, { + tooltip: 'Next', + icon: nativeImage.createFromPath(get('next')), + click() { + next(); + }, + }, + ]); + }; + + // Util + const get = (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 ''; + } + }; + + registerCallback((songInfo) => { + // Update currentsonginfo for win.on('show') + currentSongInfo = songInfo; + // Update thumbar + setThumbar(songInfo); + }); + + // Need to set thumbar again after win.show + window.on('show', () => { + setThumbar(currentSongInfo); + }); + } +}); diff --git a/src/plugins/taskbar-mediacontrol/main.ts b/src/plugins/taskbar-mediacontrol/main.ts deleted file mode 100644 index 17467734..00000000 --- a/src/plugins/taskbar-mediacontrol/main.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { BrowserWindow, nativeImage } from 'electron'; - -import getSongControls from '../../providers/song-controls'; -import registerCallback, { SongInfo } from '../../providers/song-info'; -import { mediaIcons } from '../../types/media-icons'; - -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'; - -export default (win: BrowserWindow) => { - let currentSongInfo: SongInfo; - - const { playPause, next, previous } = getSongControls(win); - - const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => { - // Wait for song to start before setting thumbar - if (!songInfo?.title) { - return; - } - - // Win32 require full rewrite of components - win.setThumbarButtons([ - { - tooltip: 'Previous', - icon: nativeImage.createFromPath(get('previous')), - click() { - previous(); - }, - }, { - tooltip: 'Play/Pause', - // Update icon based on play state - icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')), - click() { - playPause(); - }, - }, { - tooltip: 'Next', - icon: nativeImage.createFromPath(get('next')), - click() { - next(); - }, - }, - ]); - }; - - // Util - const get = (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 ''; - } - }; - - registerCallback((songInfo) => { - // Update currentsonginfo for win.on('show') - currentSongInfo = songInfo; - // Update thumbar - setThumbar(win, songInfo); - }); - - // Need to set thumbar again after win.show - win.on('show', () => { - setThumbar(win, currentSongInfo); - }); -}; diff --git a/src/plugins/touchbar/index.ts b/src/plugins/touchbar/index.ts new file mode 100644 index 00000000..ad158db9 --- /dev/null +++ b/src/plugins/touchbar/index.ts @@ -0,0 +1,98 @@ +import { type NativeImage, TouchBar } from 'electron'; + +import { createPlugin } from '@/utils'; +import getSongControls from '@/providers/song-controls'; +import registerCallback from '@/providers/song-info'; + +export default createPlugin({ + name: 'TouchBar', + restartNeeded: true, + config: { + enabled: false, + }, + backend({ window }) { + const { + TouchBarButton, + TouchBarLabel, + TouchBarSpacer, + TouchBarSegmentedControl, + TouchBarScrubber, + } = TouchBar; + + // Songtitle label + const songTitle = new TouchBarLabel({ + label: '', + }); + // This will store the song controls once available + let controls: (() => void)[] = []; + + // This will store the song image once available + const songImage: { + icon?: NativeImage; + } = {}; + + // Pause/play button + const pausePlayButton = new TouchBarButton({}); + + // The song control buttons (control functions are in the same order) + const buttons = new TouchBarSegmentedControl({ + mode: 'buttons', + segments: [ + new TouchBarButton({ + label: '⏮', + }), + pausePlayButton, + new TouchBarButton({ + label: '⏭', + }), + new TouchBarButton({ + label: '👎', + }), + new TouchBarButton({ + label: '👍', + }), + ], + change: (i) => controls[i](), + }); + + // This is the touchbar object, this combines everything with proper layout + const touchBar = new TouchBar({ + items: [ + new TouchBarScrubber({ + items: [songImage, songTitle], + continuous: false, + }), + new TouchBarSpacer({ + size: 'flexible', + }), + buttons, + ], + }); + + + const { playPause, next, previous, dislike, like } = getSongControls(window); + + // If the page is ready, register the callback + window.once('ready-to-show', () => { + controls = [previous, playPause, next, dislike, like]; + + // Register the callback + registerCallback((songInfo) => { + // Song information changed, so lets update the touchBar + + // Set the song title + songTitle.label = songInfo.title; + + // Changes the pause button if paused + pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸'; + + // Get image source + songImage.icon = songInfo.image + ? songInfo.image.resize({ height: 23 }) + : undefined; + + window.setTouchBar(touchBar); + }); + }); + } +}); diff --git a/src/plugins/touchbar/main.ts b/src/plugins/touchbar/main.ts deleted file mode 100644 index e3a27f4b..00000000 --- a/src/plugins/touchbar/main.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { TouchBar, NativeImage, BrowserWindow } from 'electron'; - -import registerCallback from '../../providers/song-info'; -import getSongControls from '../../providers/song-controls'; - -export default (win: BrowserWindow) => { - const { - TouchBarButton, - TouchBarLabel, - TouchBarSpacer, - TouchBarSegmentedControl, - TouchBarScrubber, - } = TouchBar; - - // Songtitle label - const songTitle = new TouchBarLabel({ - label: '', - }); - // This will store the song controls once available - let controls: (() => void)[] = []; - - // This will store the song image once available - const songImage: { - icon?: NativeImage; - } = {}; - - // Pause/play button - const pausePlayButton = new TouchBarButton({}); - - // The song control buttons (control functions are in the same order) - const buttons = new TouchBarSegmentedControl({ - mode: 'buttons', - segments: [ - new TouchBarButton({ - label: '⏮', - }), - pausePlayButton, - new TouchBarButton({ - label: '⏭', - }), - new TouchBarButton({ - label: '👎', - }), - new TouchBarButton({ - label: '👍', - }), - ], - change: (i) => controls[i](), - }); - - // This is the touchbar object, this combines everything with proper layout - const touchBar = new TouchBar({ - items: [ - new TouchBarScrubber({ - items: [songImage, songTitle], - continuous: false, - }), - new TouchBarSpacer({ - size: 'flexible', - }), - buttons, - ], - }); - - - const { playPause, next, previous, dislike, like } = getSongControls(win); - - // If the page is ready, register the callback - win.once('ready-to-show', () => { - controls = [previous, playPause, next, dislike, like]; - - // Register the callback - registerCallback((songInfo) => { - // Song information changed, so lets update the touchBar - - // Set the song title - songTitle.label = songInfo.title; - - // Changes the pause button if paused - pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸'; - - // Get image source - songImage.icon = songInfo.image - ? songInfo.image.resize({ height: 23 }) - : undefined; - - win.setTouchBar(touchBar); - }); - }); -}; diff --git a/src/plugins/tuna-obs/index.ts b/src/plugins/tuna-obs/index.ts new file mode 100644 index 00000000..e979ac49 --- /dev/null +++ b/src/plugins/tuna-obs/index.ts @@ -0,0 +1,89 @@ +import { net } from 'electron'; + +import is from 'electron-is'; + +import { createPlugin } from '@/utils'; +import registerCallback from '@/providers/song-info'; + +interface Data { + album: string | null | undefined; + album_url: string; + artists: string[]; + cover: string; + cover_url: string; + duration: number; + progress: number; + status: string; + title: string; +} + +export default createPlugin({ + name: 'Tuna OBS', + restartNeeded: true, + config: { + enabled: false, + }, + backend: { + data: { + cover: '', + cover_url: '', + title: '', + artists: [] as string[], + status: '', + progress: 0, + duration: 0, + album_url: '', + album: undefined, + } as Data, + start({ ipc }) { + const secToMilisec = (t: number) => Math.round(Number(t) * 1e3); + + const post = (data: Data) => { + const port = 1608; + const headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + }; + const url = `http://127.0.0.1:${port}/`; + net.fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ data }), + }).catch((error: { code: number, errno: number }) => { + if (is.dev()) { + console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`); + } + }); + }; + + ipc.on('ytmd:player-api-loaded', () => ipc.send('setupTimeChangedListener')); + ipc.on('timeChanged', (t: number) => { + if (!this.data.title) { + return; + } + + this.data.progress = secToMilisec(t); + post(this.data); + }); + + registerCallback((songInfo) => { + if (!songInfo.title && !songInfo.artist) { + return; + } + + this.data.duration = secToMilisec(songInfo.songDuration); + this.data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0); + this.data.cover = songInfo.imageSrc ?? ''; + this.data.cover_url = songInfo.imageSrc ?? ''; + this.data.album_url = songInfo.imageSrc ?? ''; + this.data.title = songInfo.title; + this.data.artists = [songInfo.artist]; + this.data.status = songInfo.isPaused ? 'stopped' : 'playing'; + this.data.album = songInfo.album; + post(this.data); + }); + } + } +}); diff --git a/src/plugins/tuna-obs/main.ts b/src/plugins/tuna-obs/main.ts deleted file mode 100644 index f08ae22d..00000000 --- a/src/plugins/tuna-obs/main.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ipcMain, net, BrowserWindow } from 'electron'; -import is from 'electron-is'; - -import registerCallback from '../../providers/song-info'; - -const secToMilisec = (t: number) => Math.round(Number(t) * 1e3); - -interface Data { - album: string | null | undefined; - album_url: string; - artists: string[]; - cover: string; - cover_url: string; - duration: number; - progress: number; - status: string; - title: string; -} - -const data: Data = { - cover: '', - cover_url: '', - title: '', - artists: [] as string[], - status: '', - progress: 0, - duration: 0, - album_url: '', - album: undefined, -}; - -const post = (data: Data) => { - const port = 1608; - const headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Access-Control-Allow-Headers': '*', - 'Access-Control-Allow-Origin': '*', - }; - const url = `http://127.0.0.1:${port}/`; - net.fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({ data }), - }).catch((error: { code: number, errno: number }) => { - if (is.dev()) { - console.debug(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`); - } - }); -}; - -export default (win: BrowserWindow) => { - ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); - ipcMain.on('timeChanged', (_, t: number) => { - if (!data.title) { - return; - } - - data.progress = secToMilisec(t); - post(data); - }); - - registerCallback((songInfo) => { - if (!songInfo.title && !songInfo.artist) { - return; - } - - data.duration = secToMilisec(songInfo.songDuration); - data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0); - 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.album = songInfo.album; - post(data); - }); -}; diff --git a/src/plugins/utils/common/index.ts b/src/plugins/utils/common/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/src/plugins/utils/common/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/plugins/utils/common/types.ts b/src/plugins/utils/common/types.ts new file mode 100644 index 00000000..1a0ef5a9 --- /dev/null +++ b/src/plugins/utils/common/types.ts @@ -0,0 +1,34 @@ +import type { BrowserWindow } from 'electron'; + +export interface Config { + enabled: boolean; +} + +export interface Plugin { + name: string; + description: string; + config: ConfigType; +} + +export interface RendererPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +export interface MainPlugin extends Plugin { + onEnable: (window: BrowserWindow, config: ConfigType) => string; +} + +export interface PreloadPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +export interface MenuPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +const defaultPluginConfig: Record = {}; +export const definePluginConfig = (id: string, defaultValue: T): T => { + defaultPluginConfig[id] = defaultValue; + + return defaultValue; +}; diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index c7dd40cc..a69f5f85 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -1,33 +1,56 @@ import fs from 'node:fs'; -const cssToInject = new Map void) | undefined>(); -const cssToInjectFile = new Map void) | undefined>(); -export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); +type Unregister = () => void; + +let isLoaded = false; + +const cssToInject = new Map void) | undefined>(); +const cssToInjectFile = new Map void) | undefined>(); +export const injectCSS = async (webContents: Electron.WebContents, css: string): Promise => { + if (isLoaded) { + const key = await webContents.insertCSS(css); + return async () => await webContents.removeInsertedCSS(key); } - cssToInject.set(css, cb); + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } + cssToInject.set(css, resolve); + }); }; -export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); +export const injectCSSAsFile = async (webContents: Electron.WebContents, filepath: string): Promise => { + if (isLoaded) { + const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); + return async () => await webContents.removeInsertedCSS(key); } - cssToInjectFile.set(filepath, cb); + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } + + cssToInjectFile.set(filepath, resolve); + }); }; const setupCssInjection = (webContents: Electron.WebContents) => { webContents.on('did-finish-load', () => { + isLoaded = true; + cssToInject.forEach(async (callback, css) => { - await webContents.insertCSS(css); - callback?.(); + const key = await webContents.insertCSS(css); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); cssToInjectFile.forEach(async (callback, filepath) => { - await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); - callback?.(); + const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); }); }; diff --git a/src/plugins/utils/main/fetch.ts b/src/plugins/utils/main/fetch.ts new file mode 100644 index 00000000..9c71c09f --- /dev/null +++ b/src/plugins/utils/main/fetch.ts @@ -0,0 +1,21 @@ +import { net } from 'electron'; + +export const getNetFetchAsFetch = () => (async (input: RequestInfo | URL, init?: RequestInit) => { + 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; diff --git a/src/plugins/utils/main/index.ts b/src/plugins/utils/main/index.ts index e185a90a..11c064fb 100644 --- a/src/plugins/utils/main/index.ts +++ b/src/plugins/utils/main/index.ts @@ -1,3 +1,4 @@ export * from './css'; export * from './fs'; -export * from './plugin'; +export * from './types'; +export * from './fetch'; diff --git a/src/plugins/utils/main/plugin.ts b/src/plugins/utils/main/plugin.ts deleted file mode 100644 index 4505e7ff..00000000 --- a/src/plugins/utils/main/plugin.ts +++ /dev/null @@ -1,16 +0,0 @@ -import is from 'electron-is'; - -import defaultConfig from '../../../config/defaults'; - -export const getAvailablePluginNames = () => { - return Object.keys(defaultConfig.plugins).filter((name) => { - if (is.windows() && name === 'touchbar') { - return false; - } else if (is.macOS() && name === 'taskbar-mediacontrol') { - return false; - } else if (is.linux() && (name === 'taskbar-mediacontrol' || name === 'touchbar')) { - return false; - } - return true; - }); -}; diff --git a/src/plugins/utils/main/types.ts b/src/plugins/utils/main/types.ts new file mode 100644 index 00000000..18e66989 --- /dev/null +++ b/src/plugins/utils/main/types.ts @@ -0,0 +1,7 @@ +import type { Config, MainPlugin, MenuPlugin, PreloadPlugin } from '../common'; + +export const defineMainPlugin = (plugin: MainPlugin) => plugin; + +export const definePreloadPlugin = (plugin: PreloadPlugin) => plugin; + +export const defineMenuPlugin = (plugin: MenuPlugin) => plugin; diff --git a/src/plugins/video-toggle/button-switcher.css b/src/plugins/video-toggle/button-switcher.css index 76d22f85..7273e820 100644 --- a/src/plugins/video-toggle/button-switcher.css +++ b/src/plugins/video-toggle/button-switcher.css @@ -1,12 +1,12 @@ -#main-panel.ytmusic-player-page { +.video-toggle-custom-mode #main-panel.ytmusic-player-page { align-items: unset !important; } -#main-panel { +.video-toggle-custom-mode #main-panel { position: relative; } -.video-switch-button { +.video-toggle-custom-mode .video-switch-button { z-index: 999; box-sizing: border-box; padding: 0; @@ -24,7 +24,7 @@ position: absolute; } -.video-switch-button:before { +.video-toggle-custom-mode .video-switch-button:before { content: "Video"; position: absolute; top: 0; @@ -38,7 +38,7 @@ pointer-events: none; } -.video-switch-button-checkbox { +.video-toggle-custom-mode .video-switch-button-checkbox { cursor: pointer; position: absolute; top: 0; @@ -50,16 +50,16 @@ z-index: 2; } -.video-switch-button-label-span { +.video-toggle-custom-mode .video-switch-button-label-span { position: relative; } -.video-switch-button-checkbox:checked + .video-switch-button-label:before { +.video-toggle-custom-mode .video-switch-button-checkbox:checked + .video-switch-button-label:before { transform: translateX(10rem); transition: transform 300ms linear; } -.video-switch-button-checkbox + .video-switch-button-label { +.video-toggle-custom-mode .video-switch-button-checkbox + .video-switch-button-label { position: relative; padding: 15px 0; display: block; @@ -67,7 +67,7 @@ pointer-events: none; } -.video-switch-button-checkbox + .video-switch-button-label:before { +.video-toggle-custom-mode .video-switch-button-checkbox + .video-switch-button-label:before { content: ""; background: rgba(60, 60, 60, 0.4); height: 100%; @@ -81,6 +81,6 @@ } /* disable the native toggler */ -#av-id { +.video-toggle-custom-mode #av-id { display: none; } diff --git a/src/plugins/video-toggle/force-hide.css b/src/plugins/video-toggle/force-hide.css index 36f47f08..77d61892 100644 --- a/src/plugins/video-toggle/force-hide.css +++ b/src/plugins/video-toggle/force-hide.css @@ -1,10 +1,10 @@ /* Hide the video player */ -#main-panel { +.video-toggle-force-hide #main-panel { display: none !important; } /* Make the side-panel full width */ -.side-panel.ytmusic-player-page { +.video-toggle-force-hide .side-panel.ytmusic-player-page { max-width: 100% !important; width: 100% !important; margin: 0 !important; diff --git a/src/plugins/video-toggle/index.ts b/src/plugins/video-toggle/index.ts new file mode 100644 index 00000000..dcfae6a6 --- /dev/null +++ b/src/plugins/video-toggle/index.ts @@ -0,0 +1,297 @@ +import buttonTemplate from './templates/button_template.html?raw'; +import forceHideStyle from './force-hide.css?inline'; +import buttonSwitcherStyle from './button-switcher.css?inline'; + +import { createPlugin } from '@/utils'; +import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '@/plugins/precise-volume/renderer'; +import { ElementFromHtml } from '@/plugins/utils/renderer'; +import { ThumbnailElement } from '@/types/get-player-response'; + +export type VideoTogglePluginConfig = { + enabled: boolean; + hideVideo: boolean; + mode: 'custom' | 'native' | 'disabled'; + forceHide: boolean; + align: 'left' | 'middle' | 'right'; +} + +export default createPlugin({ + name: 'Video Toggle', + restartNeeded: true, + config: { + enabled: false, + hideVideo: false, + mode: 'custom', + forceHide: false, + align: 'left', + } as VideoTogglePluginConfig, + stylesheets: [ + buttonSwitcherStyle, + forceHideStyle, + ], + menu: async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: 'Mode', + submenu: [ + { + label: 'Custom toggle', + type: 'radio', + checked: config.mode === 'custom', + click() { + setConfig({ mode: 'custom' }); + }, + }, + { + label: 'Native toggle', + type: 'radio', + checked: config.mode === 'native', + click() { + setConfig({ mode: 'native' }); + }, + }, + { + label: 'Disabled', + type: 'radio', + checked: config.mode === 'disabled', + click() { + setConfig({ mode: 'disabled' }); + }, + }, + ], + }, + { + label: 'Alignment', + submenu: [ + { + label: 'Left', + type: 'radio', + checked: config.align === 'left', + click() { + setConfig({ align: 'left' }); + }, + }, + { + label: 'Middle', + type: 'radio', + checked: config.align === 'middle', + click() { + setConfig({ align: 'middle' }); + }, + }, + { + label: 'Right', + type: 'radio', + checked: config.align === 'right', + click() { + setConfig({ align: 'right' }); + }, + }, + ], + }, + { + label: 'Force Remove Video Tab', + type: 'checkbox', + checked: config.forceHide, + click(item) { + setConfig({ forceHide: item.checked }); + }, + }, + ]; + }, + + renderer: { + config: null as VideoTogglePluginConfig | null, + applyStyleClass: (config: VideoTogglePluginConfig) => { + if (config.forceHide) { + document.body.classList.add('video-toggle-force-hide'); + document.body.classList.remove('video-toggle-custom-mode'); + } else if (!config.mode || config.mode === 'custom') { + document.body.classList.add('video-toggle-custom-mode'); + document.body.classList.remove('video-toggle-force-hide'); + } + }, + async start({ getConfig }) { + const config = await getConfig(); + this.applyStyleClass(config); + + if (config.forceHide) { + return; + } + + switch (config.mode) { + case 'native': { + document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', ''); + document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', ''); + return; + } + + case 'disabled': { + document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher'); + document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher'); + return; + } + } + }, + async onPlayerApiReady(api, { getConfig }) { + const config = await getConfig(); + this.config = config; + + const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? + preciseVolumeMoveVolumeHud as (_: boolean) => void + : (() => {}); + + const player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player'); + const video = document.querySelector('video'); + + const switchButtonDiv = ElementFromHtml(buttonTemplate); + + const forceThumbnail = (img: HTMLImageElement) => { + const thumbnails: ThumbnailElement[] = api?.getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? []; + if (thumbnails && thumbnails.length > 0) { + const thumbnail = thumbnails.at(-1)?.url.split('?')[0]; + if (thumbnail) img.src = thumbnail; + } + }; + + const setVideoState = (showVideo: boolean) => { + if (this.config) { + this.config.hideVideo = !showVideo; + } + window.mainConfig.plugins.setOptions('video-toggle', config); + + const checkbox = document.querySelector('.video-switch-button-checkbox'); // custom mode + if (checkbox) checkbox.checked = !config.hideVideo; + + if (player) { + player.style.margin = showVideo ? '' : 'auto 0px'; + player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); + + document.querySelector('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; + document.querySelector('#song-image')!.style.display = showVideo ? 'none' : 'block'; + + if (showVideo && video && !video.style.top) { + video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`; + } + + moveVolumeHud(showVideo); + } + }; + + const videoStarted = () => { + if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') { + // Video doesn't exist -> switch to song mode + setVideoState(false); + // Hide toggle button + switchButtonDiv.style.display = 'none'; + } else { + const songImage = document.querySelector('#song-image img'); + if (!songImage) { + return; + } + // Switch to high-res thumbnail + forceThumbnail(songImage); + // Show toggle button + switchButtonDiv.style.display = 'initial'; + // Change display to video mode if video exist & video is hidden & option.hideVideo = false + if (!this.config?.hideVideo && document.querySelector('#song-video.ytmusic-player')?.style.display === 'none') { + setVideoState(true); + } else { + moveVolumeHud(!this.config?.hideVideo); + } + } + }; + + /** + * On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container + * this function fix the problem by overriding that override :) + */ + const forcePlaybackMode = () => { + if (player) { + const playbackModeObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.target instanceof HTMLElement) { + const target = mutation.target; + if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') { + playbackModeObserver.disconnect(); + target.setAttribute('playback-mode', 'ATV_PREFERRED'); + } + } + } + }); + playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] }); + } + }; + + const observeThumbnail = () => { + const playbackModeObserver = new MutationObserver((mutations) => { + if (!player?.videoMode_) { + return; + } + + for (const mutation of mutations) { + if (mutation.target instanceof HTMLImageElement) { + const target = mutation.target; + if (!target.src.startsWith('data:')) { + continue; + } + + forceThumbnail(target); + } + } + }); + playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] }); + }; + + if (config.mode !== 'native' && config.mode != 'disabled') { + document.querySelector('#player')?.prepend(switchButtonDiv); + + setVideoState(!config.hideVideo); + forcePlaybackMode(); + // Fix black video + if (video) { + video.style.height = 'auto'; + } + + //Prevents bubbling to the player which causes it to stop or resume + switchButtonDiv.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // Button checked = show video + switchButtonDiv.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + + setVideoState(target.checked); + }); + + video?.addEventListener('srcChanged', videoStarted); + + observeThumbnail(); + + switch (config.align) { + case 'right': { + switchButtonDiv.style.left = 'calc(100% - 240px)'; + return; + } + + case 'middle': { + switchButtonDiv.style.left = 'calc(50% - 120px)'; + return; + } + + default: + case 'left': { + switchButtonDiv.style.left = '0px'; + } + } + } + }, + onConfigChange(newConfig) { + this.config = newConfig; + this.applyStyleClass(newConfig); + }, + }, +}); diff --git a/src/plugins/video-toggle/main.ts b/src/plugins/video-toggle/main.ts deleted file mode 100644 index ea53b948..00000000 --- a/src/plugins/video-toggle/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import forceHideStyle from './force-hide.css'; -import buttonSwitcherStyle from './button-switcher.css'; - -import { injectCSS } from '../utils/main'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => { - if (options.forceHide) { - injectCSS(win.webContents, forceHideStyle); - } else if (!options.mode || options.mode === 'custom') { - injectCSS(win.webContents, buttonSwitcherStyle); - } -}; diff --git a/src/plugins/video-toggle/menu.ts b/src/plugins/video-toggle/menu.ts deleted file mode 100644 index 2013d58f..00000000 --- a/src/plugins/video-toggle/menu.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import { setMenuOptions } from '../../config/plugins'; - -import { MenuTemplate } from '../../menu'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'video-toggle'>): MenuTemplate => [ - { - label: 'Mode', - submenu: [ - { - label: 'Custom toggle', - type: 'radio', - checked: options.mode === 'custom', - click() { - options.mode = 'custom'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Native toggle', - type: 'radio', - checked: options.mode === 'native', - click() { - options.mode = 'native'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Disabled', - type: 'radio', - checked: options.mode === 'disabled', - click() { - options.mode = 'disabled'; - setMenuOptions('video-toggle', options); - }, - }, - ], - }, - { - label: 'Alignment', - submenu: [ - { - label: 'Left', - type: 'radio', - checked: options.align === 'left', - click() { - options.align = 'left'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Middle', - type: 'radio', - checked: options.align === 'middle', - click() { - options.align = 'middle'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Right', - type: 'radio', - checked: options.align === 'right', - click() { - options.align = 'right'; - setMenuOptions('video-toggle', options); - }, - }, - ], - }, - { - label: 'Force Remove Video Tab', - type: 'checkbox', - checked: options.forceHide, - click(item) { - options.forceHide = item.checked; - setMenuOptions('video-toggle', options); - }, - }, -]; diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts deleted file mode 100644 index c64be509..00000000 --- a/src/plugins/video-toggle/renderer.ts +++ /dev/null @@ -1,190 +0,0 @@ -import buttonTemplate from './templates/button_template.html?raw'; - -import { ElementFromHtml } from '../utils/renderer'; - -import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer'; - -import { YoutubePlayer } from '../../types/youtube-player'; -import { ThumbnailElement } from '../../types/get-player-response'; - -import type { ConfigType } from '../../config/dynamic'; - -const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {}; - -function $(selector: string): E | null { - return document.querySelector(selector); -} - -let options: ConfigType<'video-toggle'>; -let player: HTMLElement & { videoMode_: boolean } | null; -let video: HTMLVideoElement | null; -let api: YoutubePlayer; - -const switchButtonDiv = ElementFromHtml(buttonTemplate); - -export default (_options: ConfigType<'video-toggle'>) => { - if (_options.forceHide) { - return; - } - - switch (_options.mode) { - case 'native': { - $('ytmusic-player-page')?.setAttribute('has-av-switcher', ''); - $('ytmusic-player')?.setAttribute('has-av-switcher', ''); - return; - } - - case 'disabled': { - $('ytmusic-player-page')?.removeAttribute('has-av-switcher'); - $('ytmusic-player')?.removeAttribute('has-av-switcher'); - return; - } - - default: - case 'custom': { - options = _options; - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); - } - } -}; - -function setup(e: CustomEvent) { - api = e.detail; - player = $<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player'); - video = $('video'); - - $('#player')?.prepend(switchButtonDiv); - - setVideoState(!options.hideVideo); - forcePlaybackMode(); - // Fix black video - if (video) { - video.style.height = 'auto'; - } - - //Prevents bubbling to the player which causes it to stop or resume - switchButtonDiv.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - // Button checked = show video - switchButtonDiv.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - - setVideoState(target.checked); - }); - - video?.addEventListener('srcChanged', videoStarted); - - observeThumbnail(); - - switch (options.align) { - case 'right': { - switchButtonDiv.style.left = 'calc(100% - 240px)'; - return; - } - - case 'middle': { - switchButtonDiv.style.left = 'calc(50% - 120px)'; - return; - } - - default: - case 'left': { - switchButtonDiv.style.left = '0px'; - } - } -} - -function setVideoState(showVideo: boolean) { - options.hideVideo = !showVideo; - window.mainConfig.plugins.setOptions('video-toggle', options); - - const checkbox = $('.video-switch-button-checkbox'); // custom mode - if (checkbox) checkbox.checked = !options.hideVideo; - - if (player) { - player.style.margin = showVideo ? '' : 'auto 0px'; - player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); - - $('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; - $('#song-image')!.style.display = showVideo ? 'none' : 'block'; - - if (showVideo && video && !video.style.top) { - video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`; - } - - moveVolumeHud(showVideo); - } -} - -function videoStarted() { - if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') { - // Video doesn't exist -> switch to song mode - setVideoState(false); - // Hide toggle button - switchButtonDiv.style.display = 'none'; - } else { - const songImage = $('#song-image img'); - if (!songImage) { - return; - } - // Switch to high-res thumbnail - forceThumbnail(songImage); - // Show toggle button - switchButtonDiv.style.display = 'initial'; - // Change display to video mode if video exist & video is hidden & option.hideVideo = false - if (!options.hideVideo && $('#song-video.ytmusic-player')?.style.display === 'none') { - setVideoState(true); - } else { - moveVolumeHud(!options.hideVideo); - } - } -} - -// On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container -// this function fix the problem by overriding that override :) -function forcePlaybackMode() { - if (player) { - const playbackModeObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.target instanceof HTMLElement) { - const target = mutation.target; - if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') { - playbackModeObserver.disconnect(); - target.setAttribute('playback-mode', 'ATV_PREFERRED'); - } - } - } - }); - playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] }); - } -} - -function observeThumbnail() { - const playbackModeObserver = new MutationObserver((mutations) => { - if (!player?.videoMode_) { - return; - } - - for (const mutation of mutations) { - if (mutation.target instanceof HTMLImageElement) { - const target = mutation.target; - if (!target.src.startsWith('data:')) { - continue; - } - - forceThumbnail(target); - } - } - }); - playbackModeObserver.observe($('#song-image img')!, { attributeFilter: ['src'] }); -} - -function forceThumbnail(img: HTMLImageElement) { - const thumbnails: ThumbnailElement[] = ($('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? []; - if (thumbnails && thumbnails.length > 0) { - const thumbnail = thumbnails.at(-1)?.url.split('?')[0]; - if (typeof thumbnail === 'string') img.src = thumbnail; - } -} diff --git a/src/plugins/visualizer/index.ts b/src/plugins/visualizer/index.ts new file mode 100644 index 00000000..c8923406 --- /dev/null +++ b/src/plugins/visualizer/index.ts @@ -0,0 +1,223 @@ +import emptyStyle from './empty-player.css?inline'; +import { createPlugin } from '@/utils'; +import { Visualizer } from './visualizers/visualizer'; +import { + ButterchurnVisualizer as butterchurn, + VudioVisualizer as vudio, + WaveVisualizer as wave +} from './visualizers'; + +type WaveColor = { + gradient: string[]; + rotate?: number; +}; + +export type VisualizerPluginConfig = { + enabled: boolean; + type: 'butterchurn' | 'vudio' | 'wave'; + butterchurn: { + preset: string; + renderingFrequencyInMs: number; + blendTimeInSeconds: number; + }, + vudio: { + effect: string; + accuracy: number; + lighting: { + maxHeight: number; + maxSize: number; + lineWidth: number; + color: string; + shadowBlur: number; + shadowColor: string; + fadeSide: boolean; + prettify: boolean; + horizontalAlign: string; + verticalAlign: string; + dottify: boolean; + } + }; + wave: { + animations: { + type: string; + config: { + bottom?: boolean; + top?: boolean; + count?: number; + cubeHeight?: number; + lineWidth?: number; + diameter?: number; + fillColor?: string | WaveColor; + lineColor?: string | WaveColor; + radius?: number; + frequencyBand?: string; + } + }[]; + }; +}; + +export default createPlugin({ + name: 'Visualizer', + restartNeeded: true, + config: { + 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', + }, + }, + ], + }, + } as VisualizerPluginConfig, + stylesheets: [emptyStyle], + menu: async ({ getConfig, setConfig }) => { + const config = await getConfig(); + const visualizerTypes = ['butterchurn', 'vudio', 'wave'] as const; // For bundling + + return [ + { + label: 'Type', + submenu: visualizerTypes.map((visualizerType) => ({ + label: visualizerType, + type: 'radio', + checked: config.type === visualizerType, + click() { + setConfig({ type: visualizerType }); + }, + })), + }, + ]; + }, + + renderer: { + async onPlayerApiReady(_, { getConfig }) { + const config = await getConfig(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let visualizerType: { new(...args: any[]): Visualizer } = vudio; + + if (config.type === 'wave') { + visualizerType = wave; + } else if (config.type === 'butterchurn') { + visualizerType = butterchurn; + } + + document.addEventListener( + 'audioCanPlay', + (e) => { + const video = document.querySelector('video'); + if (!video) { + return; + } + + const visualizerContainer = document.querySelector('#player'); + if (!visualizerContainer) { + return; + } + + let canvas = document.querySelector('#visualizer'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = 'visualizer'; + visualizerContainer?.prepend(canvas); + } + + const resizeCanvas = () => { + if (canvas) { + canvas.width = visualizerContainer.clientWidth; + canvas.height = visualizerContainer.clientHeight; + } + }; + + resizeCanvas(); + + const gainNode = e.detail.audioContext.createGain(); + gainNode.gain.value = 1.25; + e.detail.audioSource.connect(gainNode); + + const visualizer = new visualizerType( + e.detail.audioContext, + e.detail.audioSource, + visualizerContainer, + canvas, + gainNode, + video.captureStream(), + config, + ); + + const resizeVisualizer = (width: number, height: number) => { + resizeCanvas(); + visualizer.resize(width, height); + }; + + resizeVisualizer(canvas.width, canvas.height); + const visualizerContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + resizeVisualizer(entry.contentRect.width, entry.contentRect.height); + } + }); + visualizerContainerObserver.observe(visualizerContainer); + + visualizer.render(); + }, + { passive: true }, + ); + }, + }, +}); diff --git a/src/plugins/visualizer/main.ts b/src/plugins/visualizer/main.ts deleted file mode 100644 index 84123c6f..00000000 --- a/src/plugins/visualizer/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import emptyPlayerStyle from './empty-player.css'; - -import { injectCSS } from '../utils/main'; - -export default (win: BrowserWindow) => { - injectCSS(win.webContents, emptyPlayerStyle); -}; diff --git a/src/plugins/visualizer/menu.ts b/src/plugins/visualizer/menu.ts deleted file mode 100644 index 27b24071..00000000 --- a/src/plugins/visualizer/menu.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import { MenuTemplate } from '../../menu'; -import { setMenuOptions } from '../../config/plugins'; - -import type { ConfigType } from '../../config/dynamic'; - -const visualizerTypes = ['butterchurn', 'vudio', 'wave']; // For bundling - -export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [ - { - label: 'Type', - submenu: visualizerTypes.map((visualizerType) => ({ - label: visualizerType, - type: 'radio', - checked: options.type === visualizerType, - click() { - options.type = visualizerType; - setMenuOptions('visualizer', options); - }, - })), - }, -]; diff --git a/src/plugins/visualizer/renderer.ts b/src/plugins/visualizer/renderer.ts deleted file mode 100644 index 1fdda444..00000000 --- a/src/plugins/visualizer/renderer.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers'; -import { Visualizer } from './visualizers/visualizer'; - -import defaultConfig from '../../config/defaults'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (options: ConfigType<'visualizer'>) => { - const optionsWithDefaults = { - ...defaultConfig.plugins.visualizer, - ...options, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let visualizerType: { new(...args: any[]): Visualizer } = vudio; - - if (optionsWithDefaults.type === 'wave') { - visualizerType = wave; - } else if (optionsWithDefaults.type === 'butterchurn') { - visualizerType = butterchurn; - } - - document.addEventListener( - 'audioCanPlay', - (e) => { - const video = document.querySelector('video'); - if (!video) { - return; - } - - const visualizerContainer = document.querySelector('#player'); - if (!visualizerContainer) { - return; - } - - let canvas = document.querySelector('#visualizer'); - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.id = 'visualizer'; - visualizerContainer?.prepend(canvas); - } - - const resizeCanvas = () => { - if (canvas) { - canvas.width = visualizerContainer.clientWidth; - canvas.height = visualizerContainer.clientHeight; - } - }; - - resizeCanvas(); - - const gainNode = e.detail.audioContext.createGain(); - gainNode.gain.value = 1.25; - e.detail.audioSource.connect(gainNode); - - const visualizer = new visualizerType( - e.detail.audioContext, - e.detail.audioSource, - visualizerContainer, - canvas, - gainNode, - video.captureStream(), - optionsWithDefaults, - ); - - const resizeVisualizer = (width: number, height: number) => { - resizeCanvas(); - visualizer.resize(width, height); - }; - - resizeVisualizer(canvas.width, canvas.height); - const visualizerContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - resizeVisualizer(entry.contentRect.width, entry.contentRect.height); - } - }); - visualizerContainerObserver.observe(visualizerContainer); - - visualizer.render(); - }, - { passive: true }, - ); -}; diff --git a/src/plugins/visualizer/visualizers/butterchurn.ts b/src/plugins/visualizer/visualizers/butterchurn.ts index 6d3b070e..12c8fd31 100644 --- a/src/plugins/visualizer/visualizers/butterchurn.ts +++ b/src/plugins/visualizer/visualizers/butterchurn.ts @@ -3,7 +3,7 @@ import ButterchurnPresets from 'butterchurn-presets'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; class ButterchurnVisualizer extends Visualizer { name = 'butterchurn'; @@ -18,7 +18,7 @@ class ButterchurnVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/plugins/visualizer/visualizers/visualizer.ts b/src/plugins/visualizer/visualizers/visualizer.ts index dd8ea84d..12972dca 100644 --- a/src/plugins/visualizer/visualizers/visualizer.ts +++ b/src/plugins/visualizer/visualizers/visualizer.ts @@ -1,4 +1,4 @@ -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; export abstract class Visualizer { /** @@ -14,7 +14,7 @@ export abstract class Visualizer { _canvas: HTMLCanvasElement, _audioNode: GainNode, _stream: MediaStream, - _options: ConfigType<'visualizer'>, + _options: VisualizerPluginConfig, ) {} abstract resize(width: number, height: number): void; diff --git a/src/plugins/visualizer/visualizers/vudio.ts b/src/plugins/visualizer/visualizers/vudio.ts index 3b296eda..64167813 100644 --- a/src/plugins/visualizer/visualizers/vudio.ts +++ b/src/plugins/visualizer/visualizers/vudio.ts @@ -2,7 +2,7 @@ import Vudio from 'vudio/umd/vudio'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; class VudioVisualizer extends Visualizer { name = 'vudio'; @@ -16,7 +16,7 @@ class VudioVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/plugins/visualizer/visualizers/wave.ts b/src/plugins/visualizer/visualizers/wave.ts index fa6d2a70..c1f1c7fd 100644 --- a/src/plugins/visualizer/visualizers/wave.ts +++ b/src/plugins/visualizer/visualizers/wave.ts @@ -2,8 +2,7 @@ import { Wave } from '@foobar404/wave'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; - +import type { VisualizerPluginConfig } from '../index'; class WaveVisualizer extends Visualizer { name = 'wave'; @@ -16,7 +15,7 @@ class WaveVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/preload.ts b/src/preload.ts index dd9e6826..b5cf7618 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -3,44 +3,49 @@ import is from 'electron-is'; import config from './config'; -// eslint-disable-next-line import/order -import { preloadPlugins } from 'virtual:PreloadPlugins'; +import { + forceLoadPreloadPlugin, + forceUnloadPreloadPlugin, + loadAllPreloadPlugins, +} from './loader/preload'; -import type { ConfigType, OneOfDefaultConfigKey } from './config/dynamic'; +loadAllPreloadPlugins(); -export type PluginMapper = { - [Key in OneOfDefaultConfigKey]?: ( - Type extends 'renderer' ? (options: ConfigType) => (Promise | void) : - Type extends 'preload' ? () => (Promise | void) : - never - ) -}; - -const enabledPluginNameAndOptions = config.plugins.getEnabled(); - -enabledPluginNameAndOptions.forEach(async ([plugin, options]) => { - if (Object.hasOwn(preloadPlugins, plugin)) { - const handler = preloadPlugins[plugin]; - try { - await handler?.(options); - } catch (error) { - console.error(`Error in plugin "${plugin}": ${String(error)}`); - } - } +ipcRenderer.on('plugin:unload', async (_, id: string) => { + await forceUnloadPreloadPlugin(id); +}); +ipcRenderer.on('plugin:enable', async (_, id: string) => { + await forceLoadPreloadPlugin(id); }); contextBridge.exposeInMainWorld('mainConfig', config); contextBridge.exposeInMainWorld('electronIs', is); contextBridge.exposeInMainWorld('ipcRenderer', { - on: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.on(channel, listener), - off: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.off(channel, listener), - once: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.once(channel, listener), - send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args), - removeListener: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.removeListener(channel, listener), - removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel), - invoke: async (channel: string, ...args: unknown[]): Promise => ipcRenderer.invoke(channel, ...args), - sendSync: (channel: string, ...args: unknown[]): unknown => ipcRenderer.sendSync(channel, ...args), - sendToHost: (channel: string, ...args: unknown[]) => ipcRenderer.sendToHost(channel, ...args), + on: ( + channel: string, + listener: (event: IpcRendererEvent, ...args: unknown[]) => void, + ) => ipcRenderer.on(channel, listener), + off: (channel: string, listener: (...args: unknown[]) => void) => + ipcRenderer.off(channel, listener), + once: ( + channel: string, + listener: (event: IpcRendererEvent, ...args: unknown[]) => void, + ) => ipcRenderer.once(channel, listener), + send: (channel: string, ...args: unknown[]) => + ipcRenderer.send(channel, ...args), + removeListener: (channel: string, listener: (...args: unknown[]) => void) => + ipcRenderer.removeListener(channel, listener), + removeAllListeners: (channel: string) => + ipcRenderer.removeAllListeners(channel), + invoke: async (channel: string, ...args: unknown[]): Promise => + ipcRenderer.invoke(channel, ...args), + sendSync: (channel: string, ...args: unknown[]): unknown => + ipcRenderer.sendSync(channel, ...args), + sendToHost: (channel: string, ...args: unknown[]) => + ipcRenderer.sendToHost(channel, ...args), }); contextBridge.exposeInMainWorld('reload', () => ipcRenderer.send('reload')); -contextBridge.exposeInMainWorld('ELECTRON_RENDERER_URL', process.env.ELECTRON_RENDERER_URL); +contextBridge.exposeInMainWorld( + 'ELECTRON_RENDERER_URL', + process.env.ELECTRON_RENDERER_URL, +); diff --git a/src/providers/app-controls.ts b/src/providers/app-controls.ts index 6137871e..93f08f56 100644 --- a/src/providers/app-controls.ts +++ b/src/providers/app-controls.ts @@ -1,12 +1,10 @@ import path from 'node:path'; -import { app, BrowserWindow, ipcMain, ipcRenderer } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; -import config from '../config'; +import config from '@/config'; -export const restart = () => { - process.type === 'browser' ? restartInternal() : ipcRenderer.send('restart'); -}; +export const restart = () => restartInternal(); export const setupAppControls = () => { ipcMain.on('restart', restart); diff --git a/src/providers/prompt-options.ts b/src/providers/prompt-options.ts index 80f17f1e..43d592aa 100644 --- a/src/providers/prompt-options.ts +++ b/src/providers/prompt-options.ts @@ -1,4 +1,4 @@ -import youtubeMusicTrayIcon from '../../assets/youtube-music-tray.png?asset&asarUnpack'; +import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack'; const promptOptions = { customStylesheet: 'dark', diff --git a/src/providers/song-controls-front.ts b/src/providers/song-controls-front.ts deleted file mode 100644 index be080c81..00000000 --- a/src/providers/song-controls-front.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const setupSongControls = () => { - document.addEventListener('apiLoaded', (event) => { - window.ipcRenderer.on('seekTo', (_, t: number) => event.detail.seekTo(t)); - window.ipcRenderer.on('seekBy', (_, t: number) => event.detail.seekBy(t)); - }, { once: true, passive: true }); -}; diff --git a/src/providers/song-info-front.ts b/src/providers/song-info-front.ts index 05ae3f2a..b203a556 100644 --- a/src/providers/song-info-front.ts +++ b/src/providers/song-info-front.ts @@ -1,10 +1,11 @@ import { singleton } from './decorators'; -import type { YoutubePlayer } from '../types/youtube-player'; -import type { GetState } from '../types/datahost-get-state'; -import type { VideoDataChangeValue } from '../types/player-api-events'; +import type { YoutubePlayer } from '@/types/youtube-player'; +import type { GetState } from '@/types/datahost-get-state'; +import type { VideoDataChangeValue } from '@/types/player-api-events'; import type { SongInfo } from './song-info'; +import type { VideoDataChanged } from '@/types/video-data-changed'; let songInfo: SongInfo = {} as SongInfo; export const getSongInfo = () => songInfo; @@ -73,74 +74,74 @@ export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => { window.ipcRenderer.send('volumeChanged', api.getVolume()); }); -export default () => { - document.addEventListener('apiLoaded', (apiEvent) => { - window.ipcRenderer.on('setupTimeChangedListener', () => { - setupTimeChangedListener(); - }); +export default (api: YoutubePlayer) => { + window.ipcRenderer.on('setupTimeChangedListener', () => { + setupTimeChangedListener(); + }); - window.ipcRenderer.on('setupRepeatChangedListener', () => { - setupRepeatChangedListener(); - }); + window.ipcRenderer.on('setupRepeatChangedListener', () => { + setupRepeatChangedListener(); + }); - window.ipcRenderer.on('setupVolumeChangedListener', () => { - setupVolumeChangedListener(apiEvent.detail); - }); + window.ipcRenderer.on('setupVolumeChangedListener', () => { + setupVolumeChangedListener(api); + }); - window.ipcRenderer.on('setupSeekedListener', () => { - setupSeekedListener(); - }); + window.ipcRenderer.on('setupSeekedListener', () => { + setupSeekedListener(); + }); - const playPausedHandler = (e: Event, status: string) => { - if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) { - window.ipcRenderer.send('playPaused', { - isPaused: status === 'pause', - elapsedSeconds: Math.floor(e.target.currentTime), - }); + const playPausedHandler = (e: Event, status: string) => { + if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) { + window.ipcRenderer.send('playPaused', { + isPaused: status === 'pause', + elapsedSeconds: Math.floor(e.target.currentTime), + }); + } + }; + + const playPausedHandlers = { + playing: (e: Event) => playPausedHandler(e, 'playing'), + pause: (e: Event) => playPausedHandler(e, 'pause'), + }; + + const waitingEvent = new Set(); + // Name = "dataloaded" and abit later "dataupdated" + api.addEventListener('videodatachange', (name: string, videoData) => { + document.dispatchEvent(new CustomEvent('videodatachange', { detail: { name, videoData } })); + + if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) { + waitingEvent.delete(videoData.videoId); + sendSongInfo(videoData); + } else if (name === 'dataloaded') { + const video = $('video'); + video?.dispatchEvent(srcChangedEvent); + + for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired + video?.addEventListener(status, playPausedHandlers[status]); } - }; - const playPausedHandlers = { - playing: (e: Event) => playPausedHandler(e, 'playing'), - pause: (e: Event) => playPausedHandler(e, 'pause'), - }; + waitingEvent.add(videoData.videoId); + } + }); - const waitingEvent = new Set(); - // Name = "dataloaded" and abit later "dataupdated" - apiEvent.detail.addEventListener('videodatachange', (name: string, videoData) => { - if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) { - waitingEvent.delete(videoData.videoId); - sendSongInfo(videoData); - } else if (name === 'dataloaded') { - const video = $('video'); - video?.dispatchEvent(srcChangedEvent); + const video = $('video')!; + for (const status of ['playing', 'pause'] as const) { + video.addEventListener(status, playPausedHandlers[status]); + } - for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired - video?.addEventListener(status, playPausedHandlers[status]); - } + function sendSongInfo(videoData: VideoDataChangeValue) { + const data = api.getPlayerResponse(); - waitingEvent.add(videoData.videoId); - } - }); + data.videoDetails.album = videoData?.Hd?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album.runs?.at(0)?.text; + data.videoDetails.elapsedSeconds = 0; + data.videoDetails.isPaused = false; - const video = $('video')!; - for (const status of ['playing', 'pause'] as const) { - video.addEventListener(status, playPausedHandlers[status]); + // HACK: This is a workaround for "podcast" type video. GREAT JOB GOOGLE. + if (data.playabilityStatus.transportControlsConfig) { + data.videoDetails.author = data.microformat.microformatDataRenderer.pageOwnerDetails.name; } - function sendSongInfo(videoData: VideoDataChangeValue) { - const data = apiEvent.detail.getPlayerResponse(); - - data.videoDetails.album = videoData?.Hd?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album.runs?.at(0)?.text; - data.videoDetails.elapsedSeconds = 0; - data.videoDetails.isPaused = false; - - // HACK: This is a workaround for "podcast" type video. GREAT JOB GOOGLE. - if (data.playabilityStatus.transportControlsConfig) { - data.videoDetails.author = data.microformat.microformatDataRenderer.pageOwnerDetails.name; - } - - window.ipcRenderer.send('video-src-changed', data); - } - }, { once: true, passive: true }); + window.ipcRenderer.send('video-src-changed', data); + } }; diff --git a/src/providers/song-info.ts b/src/providers/song-info.ts index 83cfa6ca..51db27dc 100644 --- a/src/providers/song-info.ts +++ b/src/providers/song-info.ts @@ -2,9 +2,9 @@ import { BrowserWindow, ipcMain, nativeImage, net } from 'electron'; import { cache } from './decorators'; -import config from '../config'; +import config from '@/config'; -import type { GetPlayerResponse } from '../types/get-player-response'; +import type { GetPlayerResponse } from '@/types/get-player-response'; export interface SongInfo { title: string; diff --git a/src/renderer.ts b/src/renderer.ts index 4397899f..6d29118a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,71 +1,123 @@ -import setupSongInfo from './providers/song-info-front'; -import { setupSongControls } from './providers/song-controls-front'; import { startingPages } from './providers/extracted-data'; +import setupSongInfo from './providers/song-info-front'; +import { + createContext, + forceLoadRendererPlugin, + forceUnloadRendererPlugin, + getAllLoadedRendererPlugins, + getLoadedRendererPlugin, + loadAllRendererPlugins, +} from './loader/renderer'; -// eslint-disable-next-line import/order -import { rendererPlugins } from 'virtual:RendererPlugins'; +import type { PluginConfig } from '@/types/plugins'; +import type { YoutubePlayer } from '@/types/youtube-player'; -const enabledPluginNameAndOptions = window.mainConfig.plugins.getEnabled(); +let api: (Element & YoutubePlayer) | null = null; +let isPluginLoaded = false; +let isApiLoaded = false; +let firstDataLoaded = false; -let api: Element | null = null; +const observer = new MutationObserver(() => { + const playerApi = document.querySelector('#movie_player'); + if (playerApi) { + observer.disconnect(); -function listenForApiLoad() { - api = document.querySelector('#movie_player'); - if (api) { - onApiLoaded(); - return; - } + // Inject song-info provider + setupSongInfo(playerApi); + const dataLoadedListener = (name: string) => { + if (!firstDataLoaded && name === 'dataloaded') { + firstDataLoaded = true; + playerApi.removeEventListener('videodatachange', dataLoadedListener); + } + }; + playerApi.addEventListener('videodatachange', dataLoadedListener); + + if (isPluginLoaded && !isApiLoaded) { + api = playerApi; + isApiLoaded = true; - const observer = new MutationObserver(() => { - api = document.querySelector('#movie_player'); - if (api) { - observer.disconnect(); onApiLoaded(); } - }); + } +}); - observer.observe(document.documentElement, { childList: true, subtree: true }); +observer.observe(document.documentElement, { + childList: true, + subtree: true, +}); + +async function listenForApiLoad() { + if (!isApiLoaded) { + api = document.querySelector('#movie_player'); + if (api) { + await onApiLoaded(); + + return; + } + } } interface YouTubeMusicAppElement extends HTMLElement { navigate_(page: string): void; } -function onApiLoaded() { +async function onApiLoaded() { + window.ipcRenderer.on('seekTo', (_, t: number) => api!.seekTo(t)); + window.ipcRenderer.on('seekBy', (_, t: number) => api!.seekBy(t)); + const video = document.querySelector('video')!; const audioContext = new AudioContext(); const audioSource = audioContext.createMediaElementSource(video); audioSource.connect(audioContext.destination); + for await (const [id, plugin] of Object.entries(getAllLoadedRendererPlugins())) { + if (typeof plugin.renderer !== 'function') { + await plugin.renderer?.onPlayerApiReady?.call(plugin.renderer, api!, createContext(id)); + } + } + + if (firstDataLoaded) { + document.dispatchEvent(new CustomEvent('videodatachange', { detail: { name: 'dataloaded' } })); + } + + const audioCanPlayEventDispatcher = () => { + document.dispatchEvent( + new CustomEvent('audioCanPlay', { + detail: { + audioContext, + audioSource, + }, + }), + ); + }; + + const loadstartListener = () => { + // Emit "audioCanPlay" for each video + video.addEventListener( + 'canplaythrough', + audioCanPlayEventDispatcher, + { once: true }, + ); + }; + + if (video.readyState === 4 /* HAVE_ENOUGH_DATA (loaded) */) { + audioCanPlayEventDispatcher(); + } + video.addEventListener( 'loadstart', - () => { - // Emit "audioCanPlay" for each video - video.addEventListener( - 'canplaythrough', - () => { - document.dispatchEvent( - new CustomEvent('audioCanPlay', { - detail: { - audioContext, - audioSource, - }, - }), - ); - }, - { once: true }, - ); - }, + loadstartListener, { passive: true }, - );! + ); - document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api })); - window.ipcRenderer.send('apiLoaded'); + window.ipcRenderer.send('ytmd:player-api-loaded'); // Navigate to "Starting page" const startingPage: string = window.mainConfig.get('options.startingPage'); if (startingPage && startingPages[startingPage]) { - document.querySelector('ytmusic-app')?.navigate_(startingPages[startingPage]); + document + .querySelector('ytmusic-app') + ?.navigate_(startingPages[startingPage]); } // Remove upgrade button @@ -78,43 +130,61 @@ function onApiLoaded() { } // Hide / Force show like buttons - const likeButtonsOptions: string = window.mainConfig.get('options.likeButtons'); + const likeButtonsOptions: string = window.mainConfig.get( + 'options.likeButtons', + ); if (likeButtonsOptions) { - const likeButtons: HTMLElement | null = document.querySelector('ytmusic-like-button-renderer'); + const likeButtons: HTMLElement | null = document.querySelector( + 'ytmusic-like-button-renderer', + ); if (likeButtons) { - likeButtons.style.display - = { - hide: 'none', - force: 'inherit', - }[likeButtonsOptions] || ''; + likeButtons.style.display = + { + hide: 'none', + force: 'inherit', + }[likeButtonsOptions] || ''; } } } -(() => { - enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => { - if (Object.hasOwn(rendererPlugins, pluginName)) { - const handler = rendererPlugins[pluginName]; - try { - await handler?.(options as never); - } catch (error) { - console.error(`Error in plugin "${pluginName}"`); - console.trace(error); +(async () => { + await loadAllRendererPlugins(); + isPluginLoaded = true; + + window.ipcRenderer.on( + 'plugin:unload', + async (_event, id: string) => { + await forceUnloadRendererPlugin(id); + }, + ); + window.ipcRenderer.on( + 'plugin:enable', + async (_event, id: string) => { + await forceLoadRendererPlugin(id); + if (api) { + const plugin = getLoadedRendererPlugin(id); + if (plugin && typeof plugin.renderer !== 'function') { + await plugin.renderer?.onPlayerApiReady?.call(plugin.renderer, api, createContext(id)); + } } - } - }); + }, + ); - // Inject song-info provider - setupSongInfo(); - - // Inject song-controls - setupSongControls(); + window.ipcRenderer.on( + 'config-changed', + (_event, id: string, newConfig: PluginConfig) => { + const plugin = getAllLoadedRendererPlugins()[id]; + if (plugin && typeof plugin.renderer !== 'function') { + plugin.renderer?.onConfigChange?.call(plugin.renderer, newConfig); + } + }, + ); // Wait for complete load of YouTube api - listenForApiLoad(); + await listenForApiLoad(); // Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min - setInterval(() => window._lact = Date.now(), 900_000); + setInterval(() => (window._lact = Date.now()), 900_000); // Setup back to front logger if (window.electronIs.dev()) { diff --git a/src/reset.d.ts b/src/reset.d.ts index a6af3678..b0e369b3 100644 --- a/src/reset.d.ts +++ b/src/reset.d.ts @@ -1,10 +1,10 @@ import '@total-typescript/ts-reset'; -import { ipcRenderer as electronIpcRenderer } from 'electron'; -import is from 'electron-is'; +import type { ipcRenderer as electronIpcRenderer } from 'electron'; +import type is from 'electron-is'; -import config from './config'; -import { YoutubePlayer } from './types/youtube-player'; +import type config from './config'; +import type { VideoDataChanged } from '@/types/video-data-changed'; declare global { interface Compressor { @@ -13,8 +13,8 @@ declare global { } interface DocumentEventMap { - 'apiLoaded': CustomEvent; 'audioCanPlay': CustomEvent; + 'videodatachange': CustomEvent; } interface Window { diff --git a/src/tray.ts b/src/tray.ts index 093c120d..ced00e7d 100644 --- a/src/tray.ts +++ b/src/tray.ts @@ -1,10 +1,11 @@ import { Menu, nativeImage, Tray } from 'electron'; -import { restart } from './providers/app-controls'; -import config from './config'; -import getSongControls from './providers/song-controls'; +import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack'; -import youtubeMusicTrayIcon from '../assets/youtube-music-tray.png?asset&asarUnpack'; +import config from './config'; + +import { restart } from './providers/app-controls'; +import getSongControls from './providers/song-controls'; import type { MenuTemplate } from './menu'; diff --git a/src/types/contexts.ts b/src/types/contexts.ts new file mode 100644 index 00000000..e9b48f7a --- /dev/null +++ b/src/types/contexts.ts @@ -0,0 +1,34 @@ +import type { IpcMain, IpcRenderer, WebContents, BrowserWindow } from 'electron'; +import type { PluginConfig } from '@/types/plugins'; + +export interface BaseContext { + getConfig(): Promise | Config; + setConfig(conf: Partial>): Promise | void; +} + +export interface BackendContext extends BaseContext { + ipc: { + send: WebContents['send']; + handle: (event: string, listener: CallableFunction) => void; + on: (event: string, listener: CallableFunction) => void; + removeHandler: IpcMain['removeHandler']; + }; + + window: BrowserWindow; +} + +export interface MenuContext extends BaseContext { + window: BrowserWindow; + refresh: () => Promise | void; +} + +export interface PreloadContext extends BaseContext {} + +export interface RendererContext extends BaseContext { + ipc: { + send: IpcRenderer['send']; + invoke: IpcRenderer['invoke']; + on: (event: string, listener: CallableFunction) => void; + removeAllListeners: (event: string) => void; + }; +} diff --git a/src/types/plugins.ts b/src/types/plugins.ts new file mode 100644 index 00000000..ba8e600b --- /dev/null +++ b/src/types/plugins.ts @@ -0,0 +1,53 @@ +import type { YoutubePlayer } from '@/types/youtube-player'; + +import type { + BackendContext, + MenuContext, + PreloadContext, + RendererContext, +} from './contexts'; + +type Author = string; + +export type PluginConfig = { + enabled: boolean; +}; + +export type PluginLifecycleSimple = (this: This, ctx: Context) => void | Promise; +export type PluginLifecycleExtra = This & { + start?: PluginLifecycleSimple; + stop?: PluginLifecycleSimple; + onConfigChange?: (this: This, newConfig: Config) => void | Promise; +}; +export type RendererPluginLifecycleExtra = This & PluginLifecycleExtra & { + onPlayerApiReady?: (this: This, playerApi: YoutubePlayer, context: Context) => void | Promise; +} + +export type PluginLifecycle = PluginLifecycleSimple | PluginLifecycleExtra; +export type RendererPluginLifecycle = PluginLifecycleSimple | RendererPluginLifecycleExtra; + +export interface PluginDef< + BackendProperties, + PreloadProperties, + RendererProperties, + Config extends PluginConfig = PluginConfig, +> { + name: string; + authors?: Author[]; + description?: string; + config?: Config; + + menu?: (ctx: MenuContext) => Promise | Electron.MenuItemConstructorOptions[]; + stylesheets?: string[]; + restartNeeded?: boolean; + + backend?: { + [Key in keyof BackendProperties]: BackendProperties[Key] + } & PluginLifecycle, BackendProperties>; + preload?: { + [Key in keyof PreloadProperties]: PreloadProperties[Key] + } & PluginLifecycle, PreloadProperties>; + renderer?: { + [Key in keyof RendererProperties]: RendererProperties[Key] + } & RendererPluginLifecycle, RendererProperties>; +} diff --git a/src/types/video-data-changed.ts b/src/types/video-data-changed.ts new file mode 100644 index 00000000..dcdc1c3e --- /dev/null +++ b/src/types/video-data-changed.ts @@ -0,0 +1,6 @@ +import type { VideoDataChangeValue } from '@/types/player-api-events'; + +export interface VideoDataChanged { + name: string; + videoData?: VideoDataChangeValue; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..e0ab6be5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,152 @@ +import type { + BackendContext, + PreloadContext, + RendererContext, +} from '@/types/contexts'; + +import type { + PluginDef, + PluginConfig, + PluginLifecycleExtra, + PluginLifecycleSimple, + PluginLifecycle, + RendererPluginLifecycle, +} from '@/types/plugins'; + +export const LoggerPrefix = '[YTMusic]'; + +export const createPlugin = < + BackendProperties, + PreloadProperties, + RendererProperties, + Config extends PluginConfig = PluginConfig, +>( + def: PluginDef< + BackendProperties, + PreloadProperties, + RendererProperties, + Config + > & { + config?: Omit & { + enabled: boolean; + }; + }, +) => def; + +export const createBackend = < + BackendProperties, + Config extends PluginConfig = PluginConfig, +>( + back: { + [Key in keyof BackendProperties]: BackendProperties[Key]; + } & PluginLifecycle, BackendProperties>, +) => back; + +export const createPreload = < + PreloadProperties, + Config extends PluginConfig = PluginConfig, +>( + preload: { + [Key in keyof PreloadProperties]: PreloadProperties[Key]; + } & PluginLifecycle, PreloadProperties>, +) => preload; + +export const createRenderer = < + RendererProperties, + Config extends PluginConfig = PluginConfig, +>( + renderer: { + [Key in keyof RendererProperties]: RendererProperties[Key]; + } & RendererPluginLifecycle< + Config, + RendererContext, + RendererProperties + >, +) => renderer; + +type Options = + | { ctx: 'backend'; context: BackendContext } + | { ctx: 'preload'; context: PreloadContext } + | { ctx: 'renderer'; context: RendererContext }; + +export const startPlugin = async ( + id: string, + def: PluginDef, + options: Options, +) => { + const lifecycle = + typeof def[options.ctx] === 'function' + ? (def[options.ctx] as PluginLifecycleSimple) + : ( + def[options.ctx] as PluginLifecycleExtra< + Config, + typeof options.context, + unknown + > + )?.start; + + try { + // HACK: for bind 'this' to context + const defContext = def[options.ctx]; + if (defContext && typeof defContext !== 'function') { + Object.entries(defContext).forEach(([key, value]) => { + if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + defContext[key as keyof typeof defContext] = value.bind(defContext); + } + }); + } + + const start = performance.now(); + + await lifecycle?.call( + defContext, + options.context as Config & typeof options.context, + ); + + console.log( + LoggerPrefix, `Executed ${id}::${options.ctx} in ${ + performance.now() - start + } ms`, + ); + + return lifecycle ? true : null; + } catch (err) { + console.error(LoggerPrefix, `Failed to start ${id}::${options.ctx}`); + console.trace(err); + return false; + } +}; + +export const stopPlugin = async ( + id: string, + def: PluginDef, + options: Options, +) => { + if (!def || !def[options.ctx]) return false; + if (typeof def[options.ctx] === 'function') return false; + + const defCtx = def[options.ctx] as { stop: PluginLifecycleSimple } | undefined; + if (!defCtx?.stop) return null; + + try { + const stop = defCtx.stop; + const start = performance.now(); + await stop.call( + def[options.ctx], + options.context as Config & typeof options.context, + ); + + console.log( + LoggerPrefix, `Executed ${id}::${options.ctx} in ${ + performance.now() - start + } ms`, + ); + + return true; + } catch (err) { + console.error(LoggerPrefix, `Failed to execute ${id}::${options.ctx}`); + console.trace(err); + return false; + } +}; diff --git a/src/virtual-module.d.ts b/src/virtual-module.d.ts index 56daf491..ffb06766 100644 --- a/src/virtual-module.d.ts +++ b/src/virtual-module.d.ts @@ -1,26 +1,14 @@ -declare module 'virtual:MainPlugins' { - import type { BrowserWindow } from 'electron'; - import type { ConfigType } from './config/dynamic'; +declare module 'virtual:plugins' { + import type { PluginConfig, PluginDef } from '@/types/plugins'; - export const mainPlugins: Record Promise>; -} - -declare module 'virtual:MenuPlugins' { - import type { BrowserWindow } from 'electron'; - import type { MenuTemplate } from './menu'; - import type { ConfigType } from './config/dynamic'; - - export const menuPlugins: Record void) => MenuTemplate>; -} - -declare module 'virtual:PreloadPlugins' { - import type { ConfigType } from './config/dynamic'; - - export const preloadPlugins: Record Promise>; -} - -declare module 'virtual:RendererPlugins' { - import type { ConfigType } from './config/dynamic'; - - export const rendererPlugins: Record Promise>; + type Plugin = PluginDef; + + export const mainPlugins: Record; + export const preloadPlugins: Record; + export const rendererPlugins: Record; + + export const allPlugins: Record< + string, + Omit + >; } diff --git a/src/youtube-music.d.ts b/src/youtube-music.d.ts index 3eb6e491..d0622920 100644 --- a/src/youtube-music.d.ts +++ b/src/youtube-music.d.ts @@ -29,3 +29,9 @@ declare module '*.css' { export default css; } +declare module '*.css?inline' { + const css: string; + + export default css; +} + diff --git a/tsconfig.json b/tsconfig.json index fbb24d02..a42c3609 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "compilerOptions": { "target": "ESNext", "lib": ["dom", "dom.iterable", "es2022"], @@ -8,15 +9,17 @@ "esModuleInterop": true, "resolveJsonModule": true, "moduleResolution": "node", - "baseUrl": "./src", + "baseUrl": ".", "outDir": "./dist", "strict": true, "noImplicitAny": true, "strictFunctionTypes": true, - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"], + "@assets/*": ["./assets/*"] + } }, "exclude": ["./dist"], - "paths": { - "*": ["*.d.ts"] - } + "include": ["electron.vite.config.ts", "./src/**/*"] } diff --git a/vite-plugins/plugin-importer.ts b/vite-plugins/plugin-importer.ts new file mode 100644 index 00000000..58aea0f7 --- /dev/null +++ b/vite-plugins/plugin-importer.ts @@ -0,0 +1,65 @@ +import { basename, relative, resolve, extname } from 'node:path'; + +import { globSync } from 'glob'; +import { Project } from 'ts-morph'; + +const snakeToCamel = (text: string) => + text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()); + +export const pluginVirtualModuleGenerator = ( + mode: 'main' | 'preload' | 'renderer', +) => { + const project = new Project({ + tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'), + skipAddingFilesFromTsConfig: true, + skipLoadingLibFiles: true, + skipFileDependencyResolution: true, + }); + + const srcPath = resolve(__dirname, '..', 'src'); + const plugins = globSync([ + 'src/plugins/*/index.{js,ts}', + 'src/plugins/*.{js,ts}', + '!src/plugins/utils/**/*', + '!src/plugins/utils/*', + ]).map((path) => { + let name = basename(path); + if (name === 'index.ts' || name === 'index.js') { + name = basename(resolve(path, '..')); + } + + name = name.replace(extname(name), ''); + + return { name, path }; + }); + + const src = project.createSourceFile('vm:pluginIndexes', (writer) => { + // prettier-ignore + for (const { name, path } of plugins) { + const relativePath = relative(resolve(srcPath, '..'), path).replace(/\\/g, '/'); + writer.writeLine(`import ${snakeToCamel(name)}Plugin, { pluginStub as ${snakeToCamel(name)}PluginStub } from "./${relativePath}";`); + } + + writer.blankLine(); + + // Context-specific exports + writer.writeLine(`export const ${mode}Plugins = {`); + for (const { name } of plugins) { + const checkMode = mode === 'main' ? 'backend' : mode; + // HACK: To avoid situation like importing renderer plugins in main + writer.writeLine(` ...(${snakeToCamel(name)}Plugin['${checkMode}'] ? { "${name}": ${snakeToCamel(name)}Plugin } : {}),`); + } + writer.writeLine('};'); + writer.blankLine(); + + // All plugins export (stub only) // Omit + writer.writeLine('export const allPlugins = {'); + for (const { name } of plugins) { + writer.writeLine(` "${name}": ${snakeToCamel(name)}PluginStub,`); + } + writer.writeLine('};'); + writer.blankLine(); + }); + + return src.getText(); +}; diff --git a/vite-plugins/plugin-loader.ts b/vite-plugins/plugin-loader.ts new file mode 100644 index 00000000..26fef631 --- /dev/null +++ b/vite-plugins/plugin-loader.ts @@ -0,0 +1,144 @@ +import { readFile } from 'node:fs/promises'; +import { resolve, basename } from 'node:path'; + +import { createFilter } from 'vite'; +import { Project, ts, ObjectLiteralExpression, VariableDeclarationKind } from 'ts-morph'; + +import type { PluginOption } from 'vite'; + +export default function (mode: 'backend' | 'preload' | 'renderer' | 'none'): PluginOption { + const pluginFilter = createFilter([ + 'src/plugins/*/index.{js,ts}', + 'src/plugins/*', + ]); + + return { + name: 'ytm-plugin-loader', + async load(id) { + if (!pluginFilter(id)) return null; + + const project = new Project({ + tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'), + skipAddingFilesFromTsConfig: true, + skipLoadingLibFiles: true, + skipFileDependencyResolution: true, + }); + + const src = project.createSourceFile( + '_pf' + basename(id), + await readFile(id, 'utf8'), + ); + const exports = src.getExportedDeclarations(); + let objExpr: ObjectLiteralExpression | undefined = undefined; + + for (const [name, [expr]] of exports) { + if (name !== 'default') continue; + + switch (expr.getKind()) { + case ts.SyntaxKind.ObjectLiteralExpression: { + objExpr = expr.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression); + break; + } + case ts.SyntaxKind.CallExpression: { + const callExpr = expr.asKindOrThrow(ts.SyntaxKind.CallExpression); + if (callExpr.getArguments().length !== 1) continue; + + const name = callExpr.getExpression().getText(); + if (name !== 'createPlugin') continue; + + const arg = callExpr.getArguments()[0]; + if (arg.getKind() !== ts.SyntaxKind.ObjectLiteralExpression) + continue; + + objExpr = arg.asKindOrThrow(ts.SyntaxKind.ObjectLiteralExpression); + break; + } + } + } + + if (!objExpr) return null; + + const properties = objExpr.getProperties(); + const propertyNames = properties.map((prop) => { + switch (prop.getKind()) { + case ts.SyntaxKind.PropertyAssignment: + return prop + .asKindOrThrow(ts.SyntaxKind.PropertyAssignment) + .getName(); + case ts.SyntaxKind.ShorthandPropertyAssignment: + return prop + .asKindOrThrow(ts.SyntaxKind.ShorthandPropertyAssignment) + .getName(); + case ts.SyntaxKind.MethodDeclaration: + return prop + .asKindOrThrow(ts.SyntaxKind.MethodDeclaration) + .getName(); + default: + throw new Error('Not implemented'); + } + }); + + const contexts = ['backend', 'preload', 'renderer', 'menu']; + for (const ctx of contexts) { + if (mode === 'none') { + const index = propertyNames.indexOf(ctx); + if (index === -1) continue; + + objExpr.getProperty(propertyNames[index])?.remove(); + continue; + } + + if (ctx === mode) continue; + if (ctx === 'menu' && mode === 'backend') continue; + + const index = propertyNames.indexOf(ctx); + if (index === -1) continue; + + objExpr.getProperty(propertyNames[index])?.remove(); + } + + const stubObjExpr = src.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [{ + name: 'pluginStub', + initializer: (writer) => writer.write(objExpr!.getText()), + }] + }) + .getDeclarations()[0] + .getInitializer() as ObjectLiteralExpression; + + const stubProperties = stubObjExpr.getProperties(); + const stubPropertyNames = stubProperties.map((prop) => { + switch (prop.getKind()) { + case ts.SyntaxKind.PropertyAssignment: + return prop + .asKindOrThrow(ts.SyntaxKind.PropertyAssignment) + .getName(); + case ts.SyntaxKind.ShorthandPropertyAssignment: + return prop + .asKindOrThrow(ts.SyntaxKind.ShorthandPropertyAssignment) + .getName(); + case ts.SyntaxKind.MethodDeclaration: + return prop + .asKindOrThrow(ts.SyntaxKind.MethodDeclaration) + .getName(); + default: + throw new Error('Not implemented'); + } + }); + + if (mode === 'backend') contexts.pop(); + for (const ctx of contexts) { + const index = stubPropertyNames.indexOf(ctx); + if (index === -1) continue; + + stubObjExpr.getProperty(stubPropertyNames[index])?.remove(); + } + + return { + code: src.getText(), + }; + }, + }; +} diff --git a/vite-plugins/plugin-virtual-module-generator.ts b/vite-plugins/plugin-virtual-module-generator.ts deleted file mode 100644 index 4355bff2..00000000 --- a/vite-plugins/plugin-virtual-module-generator.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { existsSync } from 'node:fs'; -import { basename, relative, resolve } from 'node:path'; - -import { globSync } from 'glob'; - -const snakeToCamel = (text: string) => text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()); - -export const pluginVirtualModuleGenerator = (mode: 'main' | 'preload' | 'renderer' | 'menu') => { - const srcPath = resolve(__dirname, '..', 'src'); - - const plugins = globSync(`${srcPath}/plugins/*`) - .map((path) => ({ name: basename(path), path })) - .filter(({ name, path }) => !name.startsWith('utils') && existsSync(resolve(path, `${mode}.ts`))); - - let result = ''; - - for (const { name, path } of plugins) { - result += `import ${snakeToCamel(name)}Plugin from "./${relative(resolve(srcPath, '..'), path).replace(/\\/g, '/')}/${mode}";\n`; - } - - result += `export const ${mode}Plugins = {\n`; - for (const { name } of plugins) { - result += ` "${name}": ${snakeToCamel(name)}Plugin,\n`; - } - result += '};'; - - return result; -};