diff --git a/electron.vite.config.mts b/electron.vite.config.mts
index b4f6e318c6..af3d94286b 100644
--- a/electron.vite.config.mts
+++ b/electron.vite.config.mts
@@ -11,6 +11,7 @@ import pluginLoader from './vite-plugins/plugin-loader.mjs';
import type { UserConfig } from 'vite';
import { i18nImporter } from './vite-plugins/i18n-importer.mjs';
+import solidPlugin from 'vite-plugin-solid';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -117,6 +118,7 @@ export default defineConfig({
'virtual:i18n': i18nImporter(),
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
}),
+ solidPlugin(),
],
root: './src/',
build: {
diff --git a/package.json b/package.json
index 2da88f7e69..52e98b0731 100644
--- a/package.json
+++ b/package.json
@@ -142,6 +142,7 @@
"@electron/remote": "2.1.2",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0",
+ "@floating-ui/dom": "1.6.1",
"@foobar404/wave": "2.0.5",
"@jellybrick/electron-better-web-request": "1.0.4",
"@jellybrick/mpris-service": "2.1.4",
@@ -174,6 +175,10 @@
"semver": "7.5.4",
"serve": "14.2.1",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
+ "solid-floating-ui": "0.3.1",
+ "solid-js": "1.8.12",
+ "solid-styled-components": "0.28.5",
+ "solid-transition-group": "0.2.3",
"ts-morph": "21.0.1",
"vudio": "2.1.1",
"x11": "2.3.0",
@@ -211,6 +216,7 @@
"vite": "5.0.12",
"vite-plugin-inspect": "0.8.3",
"vite-plugin-resolve": "2.5.1",
+ "vite-plugin-solid": "2.9.1",
"ws": "8.16.0"
},
"auto-changelog": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c578eace8..c655bf64f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,6 +39,9 @@ dependencies:
'@ffmpeg.wasm/main':
specifier: 0.12.0
version: 0.12.0
+ '@floating-ui/dom':
+ specifier: 1.6.1
+ version: 1.6.1
'@foobar404/wave':
specifier: 2.0.5
version: 2.0.5
@@ -135,6 +138,18 @@ dependencies:
simple-youtube-age-restriction-bypass:
specifier: github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9
version: github.com/organization/Simple-YouTube-Age-Restriction-Bypass/4e2db89ccb2fb880c5110add9ff3f1dfb78d0ff6
+ solid-floating-ui:
+ specifier: 0.3.1
+ version: 0.3.1(@floating-ui/dom@1.6.1)(solid-js@1.8.12)
+ solid-js:
+ specifier: 1.8.12
+ version: 1.8.12
+ solid-styled-components:
+ specifier: 0.28.5
+ version: 0.28.5(solid-js@1.8.12)
+ solid-transition-group:
+ specifier: 0.2.3
+ version: 0.2.3(solid-js@1.8.12)
ts-morph:
specifier: 21.0.1
version: 21.0.1
@@ -242,6 +257,9 @@ devDependencies:
vite-plugin-resolve:
specifier: 2.5.1
version: 2.5.1
+ vite-plugin-solid:
+ specifier: 2.9.1
+ version: 2.9.1(solid-js@1.8.12)(vite@5.0.12)
ws:
specifier: 8.16.0
version: 8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3)
@@ -350,6 +368,13 @@ packages:
'@babel/types': 7.23.6
dev: true
+ /@babel/helper-module-imports@7.18.6:
+ resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.23.6
+ dev: true
+
/@babel/helper-module-imports@7.22.15:
resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==}
engines: {node: '>=6.9.0'}
@@ -433,6 +458,16 @@ packages:
'@babel/types': 7.23.6
dev: true
+ /@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.7):
+ resolution: {integrity: sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+ dependencies:
+ '@babel/core': 7.23.7
+ '@babel/helper-plugin-utils': 7.22.5
+ dev: true
+
/@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.23.7):
resolution: {integrity: sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==}
engines: {node: '>=6.9.0'}
@@ -1137,6 +1172,23 @@ packages:
regenerator-runtime: 0.13.11
dev: false
+ /@floating-ui/core@1.6.0:
+ resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
+ dependencies:
+ '@floating-ui/utils': 0.2.1
+ dev: false
+
+ /@floating-ui/dom@1.6.1:
+ resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==}
+ dependencies:
+ '@floating-ui/core': 1.6.0
+ '@floating-ui/utils': 0.2.1
+ dev: false
+
+ /@floating-ui/utils@0.2.1:
+ resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
+ dev: false
+
/@foobar404/wave@2.0.5:
resolution: {integrity: sha512-V/ydadtv5ObCw8aEg+Qy3YSq1eyinEWzJfRI43Ovmj7VmAvEdWAdL7MatoMbiIVYPATkNDVF7GOxX1xirxM9dA==}
dev: false
@@ -1466,6 +1518,31 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
+ /@solid-primitives/refs@1.0.6(solid-js@1.8.12):
+ resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==}
+ peerDependencies:
+ solid-js: ^1.6.12
+ dependencies:
+ '@solid-primitives/utils': 6.2.2(solid-js@1.8.12)
+ solid-js: 1.8.12
+ dev: false
+
+ /@solid-primitives/transition-group@1.0.4(solid-js@1.8.12):
+ resolution: {integrity: sha512-9nPg6HYAmEi7riH0C2bSCVw/2asgGSzHuN0yFFYyK9JgmXqJgyeyA+6thZbj7GgUQMRhtBxpH8yG7N2nEh8ttA==}
+ peerDependencies:
+ solid-js: ^1.6.12
+ dependencies:
+ solid-js: 1.8.12
+ dev: false
+
+ /@solid-primitives/utils@6.2.2(solid-js@1.8.12):
+ resolution: {integrity: sha512-11ypVbp987XxETeRqY5Y3OmmTpm8/jZqJXRvo6AyqBthzkvvjEdReuUMU2yVb+pwWGxfZpWHZ6EUCcGXUMhfwg==}
+ peerDependencies:
+ solid-js: ^1.6.12
+ dependencies:
+ solid-js: 1.8.12
+ dev: false
+
/@szmarczak/http-timer@4.0.6:
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -1490,6 +1567,35 @@ packages:
path-browserify: 1.0.1
dev: false
+ /@types/babel__core@7.20.5:
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+ dependencies:
+ '@babel/parser': 7.23.6
+ '@babel/types': 7.23.6
+ '@types/babel__generator': 7.6.8
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.20.5
+ dev: true
+
+ /@types/babel__generator@7.6.8:
+ resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==}
+ dependencies:
+ '@babel/types': 7.23.6
+ dev: true
+
+ /@types/babel__template@7.4.4:
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+ dependencies:
+ '@babel/parser': 7.23.6
+ '@babel/types': 7.23.6
+ dev: true
+
+ /@types/babel__traverse@7.20.5:
+ resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==}
+ dependencies:
+ '@babel/types': 7.23.6
+ dev: true
+
/@types/cacheable-request@6.0.3:
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
dependencies:
@@ -2156,6 +2262,28 @@ packages:
- debug
dev: false
+ /babel-plugin-jsx-dom-expressions@0.37.16(@babel/core@7.23.7):
+ resolution: {integrity: sha512-ItMD16axbk+FqVb9vIbc7AOpNowy46VaSUHaMYPn+erPGpMCxsahQ1Iv+qhPMthjxtn5ROVMZ5AJtQvzjxjiNA==}
+ peerDependencies:
+ '@babel/core': ^7.20.12
+ dependencies:
+ '@babel/core': 7.23.7
+ '@babel/helper-module-imports': 7.18.6
+ '@babel/plugin-syntax-jsx': 7.23.3(@babel/core@7.23.7)
+ '@babel/types': 7.23.6
+ html-entities: 2.3.3
+ validate-html-nesting: 1.2.2
+ dev: true
+
+ /babel-preset-solid@1.8.12(@babel/core@7.23.7):
+ resolution: {integrity: sha512-Fx1dYokeRwouWqjLkdobA6qvTAPxFSEU2c5PlkfJjlNyONlSMJQPaX0Bae5pc+5/LNteb9BseOp4UHwQu6VC9Q==}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ dependencies:
+ '@babel/core': 7.23.7
+ babel-plugin-jsx-dom-expressions: 0.37.16(@babel/core@7.23.7)
+ dev: true
+
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2673,6 +2801,9 @@ packages:
engines: {node: '>= 6'}
dev: false
+ /csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
/custom-electron-prompt@1.5.7(electron@29.0.0-beta.5):
resolution: {integrity: sha512-ptRPJr6CpT06GWLMtg3GD2Lr7gWfXdWI+hR1S39eq+m/mUa2E118YmX6mPCbHdg5QB/W9UVhSpRqBM8FUh1G8w==}
peerDependencies:
@@ -4069,6 +4200,14 @@ packages:
slash: 4.0.0
dev: true
+ /goober@2.1.14(csstype@3.1.3):
+ resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==}
+ peerDependencies:
+ csstype: ^3.0.10
+ dependencies:
+ csstype: 3.1.3
+ dev: false
+
/gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
@@ -4160,6 +4299,10 @@ packages:
resolution: {integrity: sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==}
dev: false
+ /html-entities@2.3.3:
+ resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
+ dev: true
+
/html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -4530,6 +4673,11 @@ packages:
get-intrinsic: 1.2.2
dev: false
+ /is-what@4.1.16:
+ resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+ engines: {node: '>=12.13'}
+ dev: true
+
/is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
@@ -4847,6 +4995,13 @@ packages:
yargs-parser: 20.2.9
dev: true
+ /merge-anything@5.1.7:
+ resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
+ engines: {node: '>=12.13'}
+ dependencies:
+ is-what: 4.1.16
+ dev: true
+
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: false
@@ -5800,6 +5955,18 @@ packages:
type-fest: 0.20.2
dev: false
+ /seroval-plugins@1.0.4(seroval@1.0.4):
+ resolution: {integrity: sha512-DQ2IK6oQVvy8k+c2V5x5YCtUa/GGGsUwUBNN9UqohrZ0rWdUapBFpNMYP1bCyRHoxOJjdKGl+dieacFIpU/i1A==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+ dependencies:
+ seroval: 1.0.4
+
+ /seroval@1.0.4:
+ resolution: {integrity: sha512-qQs/N+KfJu83rmszFQaTxcoJoPn6KNUruX4KmnmyD0oZkUoiNvJ1rpdYKDf4YHM05k+HOgCxa3yvf15QbVijGg==}
+ engines: {node: '>=10'}
+
/serve-handler@6.1.5:
resolution: {integrity: sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==}
dependencies:
@@ -5943,6 +6110,56 @@ packages:
ip: 2.0.0
smart-buffer: 4.2.0
+ /solid-floating-ui@0.3.1(@floating-ui/dom@1.6.1)(solid-js@1.8.12):
+ resolution: {integrity: sha512-o/QmGsWPS2Z3KidAxP0nDvN7alI7Kqy0kU+wd85Fz+au5SYcnYm7I6Fk3M60Za35azsPX0U+5fEtqfOuk6Ao0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@floating-ui/dom': ^1.5
+ solid-js: ^1.8
+ dependencies:
+ '@floating-ui/dom': 1.6.1
+ solid-js: 1.8.12
+ dev: false
+
+ /solid-js@1.8.12:
+ resolution: {integrity: sha512-sLE/i6M9FSWlov3a2pTC5ISzanH2aKwqXTZj+bbFt4SUrVb4iGEa7fpILBMOxsQjkv3eXqEk6JVLlogOdTe0UQ==}
+ dependencies:
+ csstype: 3.1.3
+ seroval: 1.0.4
+ seroval-plugins: 1.0.4(seroval@1.0.4)
+
+ /solid-refresh@0.6.3(solid-js@1.8.12):
+ resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
+ peerDependencies:
+ solid-js: ^1.3
+ dependencies:
+ '@babel/generator': 7.23.6
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/types': 7.23.6
+ solid-js: 1.8.12
+ dev: true
+
+ /solid-styled-components@0.28.5(solid-js@1.8.12):
+ resolution: {integrity: sha512-vwTcdp76wZNnESIzB6rRZ3U55NgcSAQXCiiRIiEFhxTFqT0bEh/warNT1qaRZu4OkAzrBkViOngF35ktI8sc4A==}
+ peerDependencies:
+ solid-js: ^1.4.4
+ dependencies:
+ csstype: 3.1.3
+ goober: 2.1.14(csstype@3.1.3)
+ solid-js: 1.8.12
+ dev: false
+
+ /solid-transition-group@0.2.3(solid-js@1.8.12):
+ resolution: {integrity: sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==}
+ engines: {node: '>=18.0.0', pnpm: '>=8.6.0'}
+ peerDependencies:
+ solid-js: ^1.6.12
+ dependencies:
+ '@solid-primitives/refs': 1.0.6(solid-js@1.8.12)
+ '@solid-primitives/transition-group': 1.0.4(solid-js@1.8.12)
+ solid-js: 1.8.12
+ dev: false
+
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@@ -6422,6 +6639,10 @@ packages:
hasBin: true
dev: false
+ /validate-html-nesting@1.2.2:
+ resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==}
+ dev: true
+
/validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
dependencies:
@@ -6476,6 +6697,28 @@ packages:
lib-esm: 0.4.2
dev: true
+ /vite-plugin-solid@2.9.1(solid-js@1.8.12)(vite@5.0.12):
+ resolution: {integrity: sha512-RC4hj+lbvljw57BbMGDApvEOPEh14lwrr/GeXRLNQLcR1qnOdzOwwTSFy13Gj/6FNIZpBEl0bWPU+VYFawrqUw==}
+ peerDependencies:
+ '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.*
+ solid-js: ^1.7.2
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0
+ peerDependenciesMeta:
+ '@testing-library/jest-dom':
+ optional: true
+ dependencies:
+ '@babel/core': 7.23.7
+ '@types/babel__core': 7.20.5
+ babel-preset-solid: 1.8.12(@babel/core@7.23.7)
+ merge-anything: 5.1.7
+ solid-js: 1.8.12
+ solid-refresh: 0.6.3(solid-js@1.8.12)
+ vite: 5.0.12(@types/node@20.11.0)
+ vitefu: 0.2.5(vite@5.0.12)
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/vite@5.0.12(@types/node@20.11.0):
resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -6512,6 +6755,17 @@ packages:
fsevents: 2.3.3
dev: true
+ /vitefu@0.2.5(vite@5.0.12):
+ resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0
+ peerDependenciesMeta:
+ vite:
+ optional: true
+ dependencies:
+ vite: 5.0.12(@types/node@20.11.0)
+ dev: true
+
/vudio@2.1.1(patch_hash=7iux5msqpgl3octdmwy4uspwoe):
resolution: {integrity: sha512-VkFQcFt/b/kpF5Eg5Sq+oXUo1Zp5aRFF4BSmIrOzau5o+5WMWwX9ae/EGJZstCyZFiCTU5iw1Y+u2BCGW6Y6Jw==}
dev: false
diff --git a/src/plugins/in-app-menu/assets/close.svg b/src/plugins/in-app-menu/assets/close.svg
deleted file mode 100644
index c43ee928a0..0000000000
--- a/src/plugins/in-app-menu/assets/close.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/plugins/in-app-menu/assets/maximize.svg b/src/plugins/in-app-menu/assets/maximize.svg
deleted file mode 100644
index 3a8a0675f0..0000000000
--- a/src/plugins/in-app-menu/assets/maximize.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/plugins/in-app-menu/assets/menu.svg b/src/plugins/in-app-menu/assets/menu.svg
deleted file mode 100644
index 49e40477ed..0000000000
--- a/src/plugins/in-app-menu/assets/menu.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/plugins/in-app-menu/assets/minimize.svg b/src/plugins/in-app-menu/assets/minimize.svg
deleted file mode 100644
index 634e1637c2..0000000000
--- a/src/plugins/in-app-menu/assets/minimize.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/plugins/in-app-menu/assets/unmaximize.svg b/src/plugins/in-app-menu/assets/unmaximize.svg
deleted file mode 100644
index 8466869b30..0000000000
--- a/src/plugins/in-app-menu/assets/unmaximize.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/plugins/in-app-menu/constants.ts b/src/plugins/in-app-menu/constants.ts
new file mode 100644
index 0000000000..9390df6a5a
--- /dev/null
+++ b/src/plugins/in-app-menu/constants.ts
@@ -0,0 +1,11 @@
+export interface InAppMenuConfig {
+ enabled: boolean;
+ hideDOMWindowControls: boolean;
+}
+export const defaultInAppMenuConfig: InAppMenuConfig = {
+ enabled:
+ (typeof window !== 'undefined' &&
+ !window.navigator?.userAgent?.includes('mac')) ||
+ (typeof global !== 'undefined' && global.process?.platform !== 'darwin'),
+ hideDOMWindowControls: false,
+};
diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts
index 1277332a01..28106184c1 100644
--- a/src/plugins/in-app-menu/index.ts
+++ b/src/plugins/in-app-menu/index.ts
@@ -2,24 +2,15 @@ import titlebarStyle from './titlebar.css?inline';
import { createPlugin } from '@/utils';
import { onMainLoad } from './main';
import { onMenu } from './menu';
-import { onPlayerApiReady, onRendererLoad } from './renderer';
+import { onConfigChange, onPlayerApiReady, onRendererLoad } from './renderer';
import { t } from '@/i18n';
+import { defaultInAppMenuConfig } from './constants';
-export interface InAppMenuConfig {
- enabled: boolean;
- hideDOMWindowControls: boolean;
-}
export default createPlugin({
name: () => t('plugins.in-app-menu.name'),
description: () => t('plugins.in-app-menu.description'),
restartNeeded: true,
- config: {
- enabled:
- (typeof window !== 'undefined' &&
- !window.navigator?.userAgent?.includes('mac')) ||
- (typeof global !== 'undefined' && global.process?.platform !== 'darwin'),
- hideDOMWindowControls: false,
- } as InAppMenuConfig,
+ config: defaultInAppMenuConfig,
stylesheets: [titlebarStyle],
menu: onMenu,
@@ -27,5 +18,6 @@ export default createPlugin({
renderer: {
start: onRendererLoad,
onPlayerApiReady,
+ onConfigChange,
},
});
diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts
index aef720ee3a..400558eabd 100644
--- a/src/plugins/in-app-menu/main.ts
+++ b/src/plugins/in-app-menu/main.ts
@@ -3,7 +3,7 @@ import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
import type { BackendContext } from '@/types/contexts';
-import type { InAppMenuConfig } from './index';
+import type { InAppMenuConfig } from './constants';
export const onMainLoad = ({
window: win,
diff --git a/src/plugins/in-app-menu/menu/icons.ts b/src/plugins/in-app-menu/menu/icons.ts
deleted file mode 100644
index 892b0bc352..0000000000
--- a/src/plugins/in-app-menu/menu/icons.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-const Icons = {
- submenu:
- '',
- checkbox:
- '',
- radio: {
- checked:
- '',
- unchecked:
- '',
- },
-};
-
-export default Icons;
diff --git a/src/plugins/in-app-menu/menu/panel.ts b/src/plugins/in-app-menu/menu/panel.ts
deleted file mode 100644
index ea9d40ee4e..0000000000
--- a/src/plugins/in-app-menu/menu/panel.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import Icons from './icons';
-
-import { ElementFromHtml } from '../../utils/renderer';
-
-import type { MenuItem } from 'electron';
-
-interface PanelOptions {
- placement?: 'bottom' | 'right';
- order?: number;
- openOnHover?: boolean;
-}
-
-export const createPanel = (
- parent: HTMLElement,
- anchor: HTMLElement,
- items: MenuItem[],
- options: PanelOptions = { placement: 'bottom', order: 0, openOnHover: false },
-) => {
- const childPanels: HTMLElement[] = [];
- const panel = document.createElement('menu-panel');
- panel.style.zIndex = `${options.order}`;
-
- const updateIconState = async (iconWrapper: HTMLElement, item: MenuItem) => {
- if (item.type === 'checkbox') {
- if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
- else iconWrapper.innerHTML = '';
- } else if (item.type === 'radio') {
- if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
- else iconWrapper.innerHTML = Icons.radio.unchecked;
- } else {
- const iconURL =
- typeof item.icon === 'string'
- ? ((await window.ipcRenderer.invoke(
- 'image-path-to-data-url',
- )) as string)
- : item.icon?.toDataURL();
-
- if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
- }
- };
-
- const radioGroups: [MenuItem, HTMLElement][] = [];
- items.map((item) => {
- if (!item.visible) return;
- if (item.type === 'separator')
- return panel.appendChild(document.createElement('menu-separator'));
-
- const menu = document.createElement('menu-item');
- const iconWrapper = document.createElement('menu-icon');
-
- updateIconState(iconWrapper, item);
- menu.appendChild(iconWrapper);
- menu.append(item.label);
-
- if (item.sublabel) {
- menu.classList.add('badge');
- const menuBadge = document.createElement('menu-item-badge');
- menuBadge.append(item.sublabel);
- menu.append(menuBadge);
- }
- if (item.toolTip) {
- const menuTooltip = document.createElement('menu-item-tooltip');
- menuTooltip.append(item.toolTip);
-
- menu.addEventListener('mouseenter', () => {
- const rect = menu.getBoundingClientRect();
- menuTooltip.style.setProperty('max-width', `${rect.width - 8}px`);
- menuTooltip.style.setProperty('--x', `${rect.left}px`);
- menuTooltip.style.setProperty('--y', `${rect.top + rect.height}px`);
- menuTooltip.classList.add('show');
- });
- menu.addEventListener('mouseleave', () => {
- menuTooltip.classList.remove('show');
- });
- parent.append(menuTooltip);
- }
-
- menu.addEventListener('click', async () => {
- await window.ipcRenderer.invoke('ytmd:menu-event', item.commandId);
- const menuItem = (await window.ipcRenderer.invoke(
- 'get-menu-by-id',
- item.commandId,
- )) as MenuItem | null;
-
- if (menuItem) {
- updateIconState(iconWrapper, menuItem);
-
- if (menuItem.type === 'radio') {
- await Promise.all(
- radioGroups.map(async ([item, iconWrapper]) => {
- if (item.commandId === menuItem.commandId) return;
- const newItem = (await window.ipcRenderer.invoke(
- 'get-menu-by-id',
- item.commandId,
- )) as MenuItem | null;
-
- if (newItem) updateIconState(iconWrapper, newItem);
- }),
- );
- }
- }
- });
-
- if (item.type === 'radio') {
- radioGroups.push([item, iconWrapper]);
- }
-
- if (item.type === 'submenu') {
- const subMenuIcon = document.createElement('menu-icon');
- subMenuIcon.appendChild(ElementFromHtml(Icons.submenu));
- menu.appendChild(subMenuIcon);
-
- const [child, , children] = createPanel(
- parent,
- menu,
- item.submenu?.items ?? [],
- {
- placement: 'right',
- order: (options?.order ?? 0) + 1,
- openOnHover: true,
- },
- );
-
- childPanels.push(child);
- childPanels.push(...children);
- }
-
- return panel.appendChild(menu);
- });
-
- /* methods */
- const isOpened = () => panel.getAttribute('open') === 'true';
- const close = () => panel.setAttribute('open', 'false');
- const open = () => {
- const rect = anchor.getBoundingClientRect();
-
- if (options.placement === 'bottom') {
- panel.style.setProperty('--x', `${rect.x}px`);
- panel.style.setProperty('--y', `${rect.y + rect.height}px`);
- } else {
- panel.style.setProperty('--x', `${rect.x + rect.width}px`);
- panel.style.setProperty('--y', `${rect.y}px`);
- }
-
- panel.setAttribute('open', 'true');
-
- // Children are placed below their parent item, which can cause
- // long lists to squeeze their children at the bottom of the screen
- // (This needs to be done *after* setAttribute)
- panel.classList.remove('position-by-bottom');
- if (
- options.placement === 'right' &&
- panel.scrollHeight > panel.clientHeight
- ) {
- panel.style.setProperty('--y', `${rect.y + rect.height}px`);
- panel.classList.add('position-by-bottom');
- }
- };
-
- if (options.openOnHover) {
- let timeout: number | null = null;
- anchor.addEventListener('mouseenter', () => {
- if (timeout) window.clearTimeout(timeout);
- timeout = window.setTimeout(() => {
- if (!isOpened()) open();
- }, 225);
- });
- anchor.addEventListener('mouseleave', () => {
- if (timeout) window.clearTimeout(timeout);
- let mouseX = 0, mouseY = 0;
- const onMouseMove = (event: MouseEvent) => {
- mouseX = event.clientX;
- mouseY = event.clientY;
- };
- document.addEventListener('mousemove', onMouseMove);
- timeout = window.setTimeout(() => {
- document.removeEventListener('mousemove', onMouseMove);
- const now = document.elementFromPoint(mouseX, mouseY);
- if (now === panel || panel.contains(now)) {
- const onLeave = () => {
- document.addEventListener('mousemove', onMouseMove);
- timeout = window.setTimeout(() => {
- document.removeEventListener('mousemove', onMouseMove);
- const now = document.elementFromPoint(mouseX, mouseY);
- if (now === panel || panel.contains(now) || childPanels.some((it) => it.contains(now))) return;
-
- if (isOpened()) close();
- panel.removeEventListener('mouseleave', onLeave);
- }, 225);
- };
- panel.addEventListener('mouseleave', onLeave);
- return;
- }
-
- if (isOpened()) close();
- }, 225);
- });
- }
-
- anchor.addEventListener('click', () => {
- if (isOpened()) close();
- else open();
- });
-
- document.body.addEventListener('click', (event) => {
- const path = event.composedPath();
- const isInside = path.some(
- (it) =>
- it === panel ||
- it === anchor ||
- childPanels.includes(it as HTMLElement),
- );
-
- if (!isInside) close();
- });
-
- parent.appendChild(panel);
-
- return [panel, { isOpened, close, open }, childPanels] as const;
-};
diff --git a/src/plugins/in-app-menu/renderer.ts b/src/plugins/in-app-menu/renderer.ts
deleted file mode 100644
index 8be84ed4b6..0000000000
--- a/src/plugins/in-app-menu/renderer.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { createPanel } from './menu/panel';
-
-import logoRaw from './assets/menu.svg?inline';
-import closeRaw from './assets/close.svg?inline';
-import minimizeRaw from './assets/minimize.svg?inline';
-import maximizeRaw from './assets/maximize.svg?inline';
-import unmaximizeRaw from './assets/unmaximize.svg?inline';
-
-import type { Menu } from 'electron';
-
-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 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');
- let maximizeButton: HTMLButtonElement;
- let panelClosers: (() => void)[] = [];
- if (isMacOS) titleBar.style.setProperty('--offset-left', '70px');
-
- const logo = document.createElement('img');
- const close = document.createElement('img');
- const minimize = document.createElement('img');
- const maximize = document.createElement('img');
- const unmaximize = document.createElement('img');
-
- if (window.ELECTRON_RENDERER_URL) {
- logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw;
- close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw;
- minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw;
- maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw;
- unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw;
- } else {
- logo.src = logoRaw;
- close.src = closeRaw;
- minimize.src = minimizeRaw;
- maximize.src = maximizeRaw;
- unmaximize.src = unmaximizeRaw;
- }
-
- logo.classList.add('title-bar-icon');
- const logoClick = () => {
- hideMenu = !hideMenu;
- let visibilityStyle: string;
- if (hideMenu) {
- visibilityStyle = 'hidden';
- } else {
- visibilityStyle = 'visible';
- }
- const menus = document.querySelectorAll('menu-button');
- menus.forEach((menu) => {
- menu.style.visibility = visibilityStyle;
- });
- };
- logo.onclick = logoClick;
-
- on('toggle-in-app-menu', logoClick);
-
- if (!isMacOS) titleBar.appendChild(logo);
- document.body.appendChild(titleBar);
-
- const addWindowControls = async () => {
- // Create window control buttons
- const minimizeButton = document.createElement('button');
- minimizeButton.classList.add('window-control');
- minimizeButton.appendChild(minimize);
- minimizeButton.onclick = () => invoke('window-minimize');
-
- maximizeButton = document.createElement('button');
- if (await invoke('window-is-maximized')) {
- maximizeButton.classList.add('window-control');
- maximizeButton.appendChild(unmaximize);
- } else {
- maximizeButton.classList.add('window-control');
- maximizeButton.appendChild(maximize);
- }
- maximizeButton.onclick = async () => {
- if (await invoke('window-is-maximized')) {
- // change icon to maximize
- maximizeButton.removeChild(maximizeButton.firstChild!);
- maximizeButton.appendChild(maximize);
-
- // call unmaximize
- await invoke('window-unmaximize');
- } else {
- // change icon to unmaximize
- maximizeButton.removeChild(maximizeButton.firstChild!);
- maximizeButton.appendChild(unmaximize);
-
- // call maximize
- await invoke('window-maximize');
- }
- };
-
- const closeButton = document.createElement('button');
- closeButton.classList.add('window-control');
- closeButton.appendChild(close);
- closeButton.onclick = () => invoke('window-close');
-
- // Create a container div for the window control buttons
- const windowControlsContainer = document.createElement('div');
- windowControlsContainer.classList.add('window-controls-container');
- windowControlsContainer.appendChild(minimizeButton);
- windowControlsContainer.appendChild(maximizeButton);
- windowControlsContainer.appendChild(closeButton);
-
- // Add window control buttons to the title bar
- titleBar.appendChild(windowControlsContainer);
- };
-
- if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls();
-
- if (navBar) {
- const observer = new MutationObserver((mutations) => {
- mutations.forEach(() => {
- titleBar.style.setProperty(
- '--titlebar-background-color',
- navBar.style.backgroundColor,
- );
- document
- .querySelector('html')!
- .style.setProperty(
- '--titlebar-background-color',
- navBar.style.backgroundColor,
- );
- });
- });
-
- observer.observe(navBar, { attributes: true, attributeFilter: ['style'] });
- }
-
- const updateMenu = async () => {
- const children = [...titleBar.children];
- children.forEach((child) => {
- if (child !== logo) child.remove();
- });
- panelClosers = [];
-
- const menu = (await invoke('get-menu')) as Menu | null;
- if (!menu) return;
-
- menu.items.forEach((menuItem) => {
- const menu = document.createElement('menu-button');
- const [, { close: closer }] = createPanel(
- titleBar,
- menu,
- menuItem.submenu?.items ?? [],
- );
- panelClosers.push(closer);
-
- menu.append(menuItem.label);
- titleBar.appendChild(menu);
- if (hideMenu) {
- menu.style.visibility = 'hidden';
- }
- });
- if (isNotWindowsOrMacOS && !hideDOMWindowControls)
- await addWindowControls();
- };
- await updateMenu();
-
- document.title = 'Youtube Music';
-
- on('close-all-in-app-menu-panel', () => {
- panelClosers.forEach((closer) => closer());
- });
- on('refresh-in-app-menu', () => updateMenu());
- on('window-maximize', () => {
- if (
- isNotWindowsOrMacOS &&
- !hideDOMWindowControls &&
- maximizeButton.firstChild
- ) {
- maximizeButton.removeChild(maximizeButton.firstChild);
- maximizeButton.appendChild(unmaximize);
- }
- });
- on('window-unmaximize', () => {
- if (
- isNotWindowsOrMacOS &&
- !hideDOMWindowControls &&
- maximizeButton.firstChild
- ) {
- maximizeButton.removeChild(maximizeButton.firstChild);
- maximizeButton.appendChild(unmaximize);
- }
- });
-
- if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
- on('pip-toggle', () => {
- updateMenu();
- });
- }
-};
-
-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/in-app-menu/renderer.tsx b/src/plugins/in-app-menu/renderer.tsx
new file mode 100644
index 0000000000..3fdb928af0
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer.tsx
@@ -0,0 +1,57 @@
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+
+import { TitleBar } from './renderer/TitleBar';
+import { defaultInAppMenuConfig, InAppMenuConfig } from './constants';
+
+import type { RendererContext } from '@/types/contexts';
+
+const scrollStyle = `
+ html::-webkit-scrollbar {
+ background-color: red;
+ }
+`;
+
+const isMacOS = navigator.userAgent.includes('Macintosh');
+const isNotWindowsOrMacOS =
+ !navigator.userAgent.includes('Windows') && !isMacOS;
+
+
+const [config, setConfig] = createSignal(defaultInAppMenuConfig);
+export const onRendererLoad = async ({
+ getConfig,
+ ipc,
+}: RendererContext) => {
+ setConfig(await getConfig());
+
+ document.title = 'YouTube Music';
+ const stylesheet = new CSSStyleSheet();
+ stylesheet.replaceSync(scrollStyle);
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
+
+ render(() => (
+
+ ), document.body);
+};
+
+export const onPlayerApiReady = () => {
+ // NOT WORKING AFTER YTM UPDATE (last checked 2024-02-04)
+ //
+ // 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 { width: 0;',
+ // );
+ // }
+};
+
+export const onConfigChange = (newConfig: InAppMenuConfig) => {
+ setConfig(newConfig);
+};
diff --git a/src/plugins/in-app-menu/renderer/IconButton.tsx b/src/plugins/in-app-menu/renderer/IconButton.tsx
new file mode 100644
index 0000000000..2b29ba5ffc
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer/IconButton.tsx
@@ -0,0 +1,40 @@
+import { JSX } from 'solid-js';
+import { css } from 'solid-styled-components';
+
+const iconButton = css`
+ background: transparent;
+
+ width: 24px;
+ height: 24px;
+
+ padding: 2px;
+ border-radius: 2px;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ color: white;
+
+ outline: none;
+ border: none;
+
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ &:active {
+ scale: 0.9;
+ }
+`;
+
+type CollapseIconButtonProps = JSX.HTMLAttributes;
+export const IconButton = (props: CollapseIconButtonProps) => {
+ return (
+
+ );
+};
diff --git a/src/plugins/in-app-menu/renderer/MenuButton.tsx b/src/plugins/in-app-menu/renderer/MenuButton.tsx
new file mode 100644
index 0000000000..da17f84865
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer/MenuButton.tsx
@@ -0,0 +1,42 @@
+import { JSX, splitProps } from 'solid-js';
+import { css } from 'solid-styled-components';
+
+const menuStyle = css`
+ -webkit-app-region: none;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ align-self: stretch;
+
+ padding: 2px 8px;
+ border-radius: 4px;
+
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+ &:active {
+ scale: 0.9;
+ }
+
+ &[data-selected="true"] {
+ background-color: rgba(255, 255, 255, 0.2);
+ }
+`;
+
+export type MenuButtonProps = JSX.HTMLAttributes & {
+ text?: string;
+ selected?: boolean;
+};
+export const MenuButton = (props: MenuButtonProps) => {
+ const [local, leftProps] = splitProps(props, ['text']);
+
+ return (
+
+ );
+};
diff --git a/src/plugins/in-app-menu/renderer/Panel.tsx b/src/plugins/in-app-menu/renderer/Panel.tsx
new file mode 100644
index 0000000000..6701a0d15e
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer/Panel.tsx
@@ -0,0 +1,148 @@
+import { createSignal, JSX, Show, splitProps } from 'solid-js';
+import { mergeProps, Portal } from 'solid-js/web';
+import { css } from 'solid-styled-components';
+import { Transition } from 'solid-transition-group';
+import { autoUpdate, flip, offset, OffsetOptions, size } from '@floating-ui/dom';
+import { useFloating } from 'solid-floating-ui';
+
+const panelStyle = css`
+ position: fixed;
+ top: var(--offset-y, 0);
+ left: var(--offset-x, 0);
+
+ max-width: var(--max-width, 100%);
+ max-height: var(--max-height, 100%);
+
+ z-index: 10000;
+ width: fit-content;
+ height: fit-content;
+
+ padding: 4px;
+ box-sizing: border-box;
+ border-radius: 8px;
+ overflow: auto;
+
+ background-color: color-mix(
+ in srgb,
+ var(--titlebar-background-color, #030303) 50%,
+ rgba(0, 0, 0, 0.1)
+ );
+ backdrop-filter: blur(8px);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05),
+ 0 2px 8px rgba(0, 0, 0, 0.2);
+
+ transform-origin: var(--origin-x, 50%) var(--origin-y, 50%);
+`;
+
+const animationStyle = {
+ enter: css`
+ opacity: 0;
+ transform: scale(0.9);
+ `,
+ enterActive: css`
+ transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
+ `,
+ exitTo: css`
+ opacity: 0;
+ transform: scale(0.9);
+ `,
+ exitActive: css`
+ transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
+ `,
+};
+
+export type Placement =
+ 'top'
+ | 'bottom'
+ | 'left'
+ | 'right'
+ | 'top-start'
+ | 'top-end'
+ | 'bottom-start'
+ | 'bottom-end'
+ | 'right-start'
+ | 'right-end'
+ | 'left-start'
+ | 'left-end';
+export type PanelProps = JSX.HTMLAttributes & {
+ open?: boolean;
+ anchor?: HTMLElement | null;
+ children: JSX.Element;
+
+ placement?: Placement;
+ offset?: OffsetOptions;
+};
+export const Panel = (props: PanelProps) => {
+ const [elements, local, leftProps] = splitProps(
+ mergeProps({ placement: 'bottom' }, props),
+ ['anchor', 'children'],
+ ['open', 'placement', 'offset'],
+ );
+
+ const [panel, setPanel] = createSignal(null);
+
+ const position = useFloating(() => elements.anchor, panel, {
+ whileElementsMounted: autoUpdate,
+ placement: local.placement as Placement,
+ strategy: 'fixed',
+ middleware: [
+ offset(local.offset),
+ size({
+ padding: 8,
+ apply({ elements, availableWidth, availableHeight }) {
+ elements.floating.style.setProperty('--max-width', `${Math.max(200, availableWidth)}px`);
+ elements.floating.style.setProperty('--max-height', `${Math.max(200, availableHeight)}px`);
+ }
+ }),
+ flip({ fallbackStrategy: 'initialPlacement' }),
+ ],
+ });
+
+ const originX = () => {
+ if (position.placement.includes('left')) return '100%';
+ if (position.placement.includes('right')) return '0';
+ if (position.placement.includes('top') || position.placement.includes('bottom')) {
+ if (position.placement.includes('start')) return '0';
+ if (position.placement.includes('end')) return '100%';
+ }
+
+ return '50%';
+ };
+ const originY = () => {
+ if (position.placement.includes('top')) return '100%';
+ if (position.placement.includes('bottom')) return '0';
+ if (position.placement.includes('left') || position.placement.includes('right')) {
+ if (position.placement.includes('start')) return '0';
+ if (position.placement.includes('end')) return '100%';
+ }
+ return '50%';
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/plugins/in-app-menu/renderer/PanelItem.tsx b/src/plugins/in-app-menu/renderer/PanelItem.tsx
new file mode 100644
index 0000000000..ec11ce41f4
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer/PanelItem.tsx
@@ -0,0 +1,331 @@
+import { createSignal, Match, Show, Switch } from 'solid-js';
+import { JSX } from 'solid-js/jsx-runtime';
+import { css } from 'solid-styled-components';
+import { Portal } from 'solid-js/web';
+
+import { Transition } from 'solid-transition-group';
+import { useFloating } from 'solid-floating-ui';
+import { autoUpdate, offset, size } from '@floating-ui/dom';
+
+import { Panel } from './Panel';
+
+const itemStyle = css`
+ position: relative;
+
+ -webkit-app-region: none;
+ min-height: 32px;
+ height: 32px;
+
+ display: grid;
+ grid-template-columns: 32px 1fr auto minmax(32px, auto);
+ justify-content: flex-start;
+ align-items: center;
+
+ border-radius: 4px;
+ cursor: pointer;
+ box-sizing: border-box;
+ user-select: none;
+ -webkit-user-drag: none;
+
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ &:active {
+ background-color: rgba(255, 255, 255, 0.2);
+ }
+
+ &[data-selected="true"] {
+ background-color: rgba(255, 255, 255, 0.2);
+ }
+
+ & * {
+ box-sizing: border-box;
+ }
+`;
+
+const itemIconStyle = css`
+ height: 32px;
+ padding: 4px;
+ color: white;
+`;
+
+const itemLabelStyle = css`
+ font-size: 12px;
+ color: white;
+`;
+
+const itemChipStyle = css`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ margin-left: 8px;
+
+ border-radius: 4px;
+ background-color: rgba(255, 255, 255, 0.2);
+ color: #f1f1f1;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1;
+`;
+
+const toolTipStyle = css`
+ min-width: 32px;
+ width: 100%;
+ height: 100%;
+
+ padding: 4px;
+
+ max-width: calc(var(--max-width, 100%) - 8px);
+ max-height: calc(var(--max-height, 100%) - 8px);
+
+ border-radius: 4px;
+ background-color: rgba(25, 25, 25, 0.8);
+ color: #f1f1f1;
+ font-size: 10px;
+`;
+
+const popupStyle = css`
+ position: fixed;
+ top: var(--offset-y, 0);
+ left: var(--offset-x, 0);
+
+ max-width: var(--max-width, 100%);
+ max-height: var(--max-height, 100%);
+
+ z-index: 100000000;
+ pointer-events: none;
+
+`;
+const animationStyle = {
+ enter: css`
+ opacity: 0;
+ transform: scale(0.9);
+ `,
+ enterActive: css`
+ transition: opacity 0.225s cubic-bezier(0.33, 1, 0.68, 1), transform 0.225s cubic-bezier(0.33, 1, 0.68, 1);
+ `,
+ exitTo: css`
+ opacity: 0;
+ transform: scale(0.9);
+ `,
+ exitActive: css`
+ transition: opacity 0.225s cubic-bezier(0.32, 0, 0.67, 0), transform 0.225s cubic-bezier(0.32, 0, 0.67, 0);
+ `,
+};
+
+const getParents = (element: Element | null): (HTMLElement | null)[] => {
+ const parents: (HTMLElement | null)[] = [];
+ let now = element;
+
+ while (now) {
+ parents.push(now as HTMLElement | null);
+ now = now.parentElement;
+ }
+
+ return parents;
+};
+
+type BasePanelItemProps = {
+ name: string;
+ label?: string;
+ chip?: string;
+ toolTip?: string;
+ commandId?: number;
+};
+type NormalPanelItemProps = BasePanelItemProps & {
+ type: 'normal';
+ onClick?: () => void;
+};
+type SubmenuItemProps = BasePanelItemProps & {
+ type: 'submenu';
+ level: number[];
+ children: JSX.Element;
+};
+type RadioPanelItemProps = BasePanelItemProps & {
+ type: 'radio';
+ checked: boolean;
+ onChange?: (checked: boolean) => void;
+};
+type CheckboxPanelItemProps = BasePanelItemProps & {
+ type: 'checkbox';
+ checked: boolean;
+ onChange?: (checked: boolean) => void;
+};
+export type PanelItemProps = NormalPanelItemProps | SubmenuItemProps | RadioPanelItemProps | CheckboxPanelItemProps;
+export const PanelItem = (props: PanelItemProps) => {
+ const [open, setOpen] = createSignal(false);
+ const [toolTipOpen, setToolTipOpen] = createSignal(false);
+ const [toolTip, setToolTip] = createSignal(null);
+ const [anchor, setAnchor] = createSignal(null);
+ const [child, setChild] = createSignal(null);
+
+ const position = useFloating(anchor, toolTip, {
+ whileElementsMounted: autoUpdate,
+ placement: 'bottom-start',
+ strategy: 'fixed',
+ middleware: [
+ offset({ mainAxis: 8 }),
+ size({
+ apply({ rects, elements }) {
+ elements.floating.style.setProperty('--max-width', `${rects.reference.width}px`);
+ }
+ }),
+ ],
+ });
+
+ const handleHover = (event: MouseEvent) => {
+ setToolTipOpen(true);
+ event.target?.addEventListener('mouseleave', () => {
+ setToolTipOpen(false);
+ }, { once: true });
+
+ if (props.type === 'submenu') {
+ const timer = setTimeout(() => {
+ setOpen(true);
+
+ let mouseX = event.clientX;
+ let mouseY = event.clientY;
+ const onMouseMove = (event: MouseEvent) => {
+ mouseX = event.clientX;
+ mouseY = event.clientY;
+ };
+ document.addEventListener('mousemove', onMouseMove);
+
+ event.target?.addEventListener('mouseleave', () => {
+ setTimeout(() => {
+ document.removeEventListener('mousemove', onMouseMove);
+ const parents = getParents(document.elementFromPoint(mouseX, mouseY));
+
+ if (!parents.includes(child())) {
+ setOpen(false);
+ } else {
+ const onOtherHover = (event: MouseEvent) => {
+ const parents = getParents(event.target as HTMLElement);
+ const closestLevel = parents.find((it) => it?.dataset?.level)?.dataset.level ?? '';
+ const path = event.composedPath();
+
+ const isOtherItem = path.some((it) => it instanceof HTMLElement && it.classList.contains(itemStyle));
+ const isChild = closestLevel.startsWith(props.level.join('/'));
+
+ if (isOtherItem && !isChild) {
+ setOpen(false);
+ document.removeEventListener('mousemove', onOtherHover);
+ }
+ };
+ document.addEventListener('mousemove', onOtherHover);
+ }
+ }, 225);
+ }, { once: true });
+ }, 225);
+
+ event.target?.addEventListener('mouseleave', () => {
+ clearTimeout(timer);
+ }, { once: true });
+ }
+ };
+
+ const handleClick = async () => {
+ await window.ipcRenderer.invoke('ytmd:menu-event', props.commandId);
+ if (props.type === 'radio') {
+ props.onChange?.(!props.checked);
+ } else if (props.type === 'checkbox') {
+ props.onChange?.(!props.checked);
+ } else if (props.type === 'normal') {
+ props.onClick?.();
+ }
+ };
+
+
+ return (
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+ {props.name}
+
+ }>
+
+ {props.chip}
+
+
+
+
+
+ {props.type === 'submenu' && props.children}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/plugins/in-app-menu/renderer/TitleBar.tsx b/src/plugins/in-app-menu/renderer/TitleBar.tsx
new file mode 100644
index 0000000000..58a8db5854
--- /dev/null
+++ b/src/plugins/in-app-menu/renderer/TitleBar.tsx
@@ -0,0 +1,342 @@
+import { Menu, MenuItem } from 'electron';
+import { createEffect, createResource, createSignal, Index, Match, onMount, Show, Switch } from 'solid-js';
+import { css } from 'solid-styled-components';
+import { TransitionGroup } from 'solid-transition-group';
+
+import { MenuButton } from './MenuButton';
+import { Panel } from './Panel';
+import { PanelItem } from './PanelItem';
+import { IconButton } from './IconButton';
+import { WindowController } from './WindowController';
+
+import type { RendererContext } from '@/types/contexts';
+import type { InAppMenuConfig } from '../constants';
+
+const titleStyle = css`
+ -webkit-app-region: drag;
+ box-sizing: border-box;
+
+ position: fixed;
+ top: 0;
+ z-index: 10000000;
+
+ width: 100%;
+ height: var(--menu-bar-height, 32px);
+
+ display: flex;
+ flex-flow: row;
+ justify-content: flex-start;
+ align-items: center;
+ gap: 4px;
+
+ color: #f1f1f1;
+ font-size: 12px;
+ padding: 4px 4px 4px var(--offset-left, 4px);
+ background-color: var(--titlebar-background-color, #030303);
+ user-select: none;
+
+ transition: opacity 200ms ease 0s,
+ background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) 0s;
+
+ &[data-macos="true"] {
+ padding: 4px 4px 4px 74px;
+ }
+`;
+
+const separatorStyle = css`
+ min-height: 1px;
+ height: 1px;
+ margin: 4px 0;
+
+ background-color: rgba(255, 255, 255, 0.2);
+`;
+
+const animationStyle = {
+ enter: css`
+ opacity: 0;
+ transform: translateX(-50%) scale(0.8);
+ `,
+ enterActive: css`
+ transition: opacity 0.1s cubic-bezier(0.33, 1, 0.68, 1), transform 0.1s cubic-bezier(0.33, 1, 0.68, 1);
+ `,
+ exitTo: css`
+ opacity: 0;
+ transform: translateX(-50%) scale(0.8);
+ `,
+ exitActive: css`
+ transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0), transform 0.1s cubic-bezier(0.32, 0, 0.67, 0);
+ `,
+ move: css`
+ transition: all 0.1s cubic-bezier(0.65, 0, 0.35, 1);
+ `,
+ fakeTarget: css`
+ position: absolute;
+ opacity: 0;
+ `,
+ fake: css`
+ transition: all 0.00000000001s;
+ `,
+};
+
+export type PanelRendererProps = {
+ items: Electron.Menu['items'];
+ level?: number[];
+ onClick?: (commandId: number, radioGroup?: MenuItem[]) => void;
+}
+const PanelRenderer = (props: PanelRendererProps) => {
+ const radioGroup = () => props.items.filter((it) => it.type === 'radio');
+
+ return (
+
+ {(subItem) => (
+
+
+
+ props.onClick?.(subItem().commandId)}
+ />
+
+
+
+
+
+
+
+ props.onClick?.(subItem().commandId)}
+ />
+
+
+ props.onClick?.(subItem().commandId, radioGroup())}
+ />
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export type TitleBarProps = {
+ ipc: RendererContext['ipc'];
+ isMacOS?: boolean;
+ enableController?: boolean;
+ initialCollapsed?: boolean;
+};
+export const TitleBar = (props: TitleBarProps) => {
+ const [collapsed, setCollapsed] = createSignal(props.initialCollapsed);
+ const [ignoreTransition, setIgnoreTransition] = createSignal(false);
+ const [openTarget, setOpenTarget] = createSignal(null);
+ const [menu, setMenu] = createSignal