Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e151ab4f26 | ||
|
|
e28f61447f | ||
|
|
388552f1c4 | ||
|
|
e68e73d3d5 | ||
|
|
a5669805c8 | ||
|
|
cef7336517 | ||
|
|
50a3c1a1e5 | ||
|
|
684f5ce866 | ||
|
|
80da40f678 | ||
|
|
3cca2f41f4 | ||
|
|
6511d9d007 | ||
|
|
d5a2afdea5 | ||
|
|
24830172ae | ||
|
|
fd7dd89f55 | ||
|
|
dac6c729cd | ||
|
|
d48155683b | ||
|
|
e1d6398d57 | ||
|
|
4aae47cd27 | ||
|
|
61f821c3e9 | ||
|
|
b74857d7f6 | ||
|
|
d3a783f77f | ||
|
|
f965779373 | ||
|
|
b0d4e372cb | ||
|
|
e893cab082 | ||
|
|
10b890b5ae | ||
|
|
e05d589311 | ||
|
|
00b6721c42 | ||
|
|
4f2bce1f5f | ||
|
|
8c0b272ab9 | ||
|
|
772a331f04 | ||
|
|
1643a887af | ||
|
|
8e9a59d026 | ||
|
|
1792910ca1 | ||
|
|
faa019d52b | ||
|
|
f6bc46dd98 | ||
|
|
30e1c0b0a9 | ||
|
|
9f2ace9698 | ||
|
|
1e00ca3a7c | ||
|
|
2717dfe99a | ||
|
|
fd545fb2a8 | ||
|
|
3793ee9f5b | ||
|
|
014ccf5197 | ||
|
|
0f198692ad | ||
|
|
19c3aedf80 | ||
|
|
9589f6f3f0 | ||
|
|
037d17ff3d | ||
|
|
20182a8e36 | ||
|
|
8f5a7c22cc | ||
|
|
ac23f98fcd |
@@ -7,5 +7,25 @@ This template should help get you started developing with Tauri, React and Types
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
# TODO
|
||||
|
||||
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
|
||||
- Check render to improve perfoormances
|
||||
|
||||
- Check render to improve perfoormances
|
||||
|
||||
- Check in meter if clip bool is limit instead
|
||||
|
||||
- On close disconenct all ds^p
|
||||
|
||||
- Check end to end behavioour
|
||||
|
||||
- Roll back on scroll when 2 scroll
|
||||
|
||||
- test en dto end connection
|
||||
|
||||
Data: 10020001052f 31313131 10032a // lock
|
||||
|
||||
Data: 10020001052d 31313131 100328 // unlock fail
|
||||
Data: 10020100032d 3100 10031f
|
||||
|
||||
Data: 10020001062d 0031313131 10032b // unlock success
|
||||
Data: 10020100032d 0001 10032f
|
||||
@@ -1097,9 +1097,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1114,9 +1111,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1131,9 +1125,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1148,9 +1139,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1165,9 +1153,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1182,9 +1167,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1199,9 +1181,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1216,9 +1195,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1233,9 +1209,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1250,9 +1223,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1267,9 +1237,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1284,9 +1251,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1301,9 +1265,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1493,9 +1454,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1513,9 +1471,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1533,9 +1488,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1553,9 +1505,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1573,9 +1522,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1809,9 +1755,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.44",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
|
||||
"integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
|
||||
"version": "2.11.21",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||
"integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1822,9 +1768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -1832,13 +1778,13 @@
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.6",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
|
||||
"version": "4.28.9",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||
"integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1856,11 +1802,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001803",
|
||||
"electron-to-chromium": "^1.5.389",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"electron-to-chromium": "^1.5.420",
|
||||
"node-releases": "^2.0.54",
|
||||
"update-browserslist-db": "^1.3.2"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -1870,9 +1816,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1947,9 +1893,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.394",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz",
|
||||
"integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==",
|
||||
"version": "1.5.422",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz",
|
||||
"integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -2562,9 +2508,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2589,9 +2535,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2694,9 +2640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.21",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||
"integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2714,7 +2660,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2962,9 +2908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -175,7 +175,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -225,6 +225,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -397,7 +403,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -453,6 +459,17 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -543,6 +560,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -618,9 +644,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
@@ -628,26 +654,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -661,6 +687,37 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -796,11 +853,13 @@ dependencies = [
|
||||
name = "dsp_thomann"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1254,6 +1313,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1761,6 +1821,60 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2004,7 +2118,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2492,6 +2606,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2605,6 +2734,23 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2628,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2648,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2882,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2917,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2940,16 +3086,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
@@ -2960,14 +3107,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.21.0"
|
||||
version = "3.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3008,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3198,9 +3345,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.2"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3299,9 +3446,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.5"
|
||||
version = "2.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
||||
checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -3338,7 +3485,7 @@ dependencies = [
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tray-icon",
|
||||
"url",
|
||||
@@ -3389,7 +3536,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"syn 2.0.119",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -3428,9 +3575,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
version = "2.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
@@ -3442,7 +3589,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
@@ -3466,7 +3613,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
@@ -3529,7 +3676,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
@@ -3581,11 +3728,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.19",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3601,13 +3748,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3918,7 +4065,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4278,7 +4425,7 @@ version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
@@ -4713,7 +4860,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.19",
|
||||
"thiserror 2.0.21",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
|
||||
@@ -18,8 +18,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri = { version = "2.11.6", features = [] }
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dsp_thomann = { path = "../../dsp_thomann", features = ["serialization"] }
|
||||
dsp_thomann = { path = "../../dsp-thomann", features = ["serialization"] }
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,87 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="200"
|
||||
height="200"
|
||||
viewBox="0 0 200 200"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="richGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stop-color="#1e3a8a" stop-opacity="1"/>
|
||||
<stop offset="25%" stop-color="#2d3485"/>
|
||||
<stop offset="45%" stop-color="#4c2e7f"/>
|
||||
<stop offset="60%" stop-color="#6b2878"/>
|
||||
<stop offset="80%" stop-color="#b9326e"/>
|
||||
<stop offset="100%" stop-color="#ea437a" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient
|
||||
id="darkenGradient"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
r="50%"
|
||||
>
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.3"/>
|
||||
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.1"/>
|
||||
<stop offset="60%" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="80%" stop-color="#000000" stop-opacity="0.2"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0.5"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient
|
||||
id="shadowGradient"
|
||||
x1="20%"
|
||||
y1="20%"
|
||||
x2="80%"
|
||||
y2="80%"
|
||||
>
|
||||
<stop offset="0%" stop-color="#0f1d42"/>
|
||||
<stop offset="25%" stop-color="#1f2a5d"/>
|
||||
<stop offset="45%" stop-color="#332f78"/>
|
||||
<stop offset="60%" stop-color="#5f3458"/>
|
||||
<stop offset="80%" stop-color="#7a2250"/>
|
||||
<stop offset="100%" stop-color="#a50d57"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main donut -->
|
||||
<path
|
||||
d="
|
||||
M100,20
|
||||
A80,80 0 1,1 99.9,20
|
||||
M100,60
|
||||
A40,40 0 1,0 100.1,60
|
||||
"
|
||||
fill="url(#richGradient)"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
|
||||
<!-- Darkening / depth overlay -->
|
||||
<path
|
||||
d="
|
||||
M100,20
|
||||
A80,80 0 1,1 99.9,20
|
||||
M100,60
|
||||
A40,40 0 1,0 100.1,60
|
||||
"
|
||||
fill="url(#darkenGradient)"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
|
||||
<!-- Inner shadow -->
|
||||
<path
|
||||
d="
|
||||
M100,60
|
||||
A40,40 0 1,0 100.1,60
|
||||
M100,75
|
||||
A25,25 0 1,1 99.9,75
|
||||
"
|
||||
fill="url(#shadowGradient)"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -41,16 +41,28 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DSP408Snapshot {
|
||||
pub state: types::DSPState,
|
||||
pub input_meters: types::InputMeters,
|
||||
pub output_meters: types::OutputMeters,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_dsp408(
|
||||
state: State<AppState>,
|
||||
ip: String,
|
||||
port: u16,
|
||||
device_id: u8,
|
||||
demo: bool,
|
||||
) -> Result<u64, String> {
|
||||
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
||||
|
||||
let dsp = DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?;
|
||||
let dsp = if demo {
|
||||
DSP408::new_demo()
|
||||
} else {
|
||||
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -82,14 +94,16 @@ pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||
pub async fn get_dsp_snapshot(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
) -> Result<DSP408Snapshot, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.disconnect().map_err(|e| e.to_string())
|
||||
Ok(DSP408Snapshot {
|
||||
state: device.dsp.state().clone(),
|
||||
input_meters: device.dsp.input_meters().clone(),
|
||||
output_meters: device.dsp.output_meters().clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,63 +113,39 @@ pub async fn get_dsp_state(
|
||||
id: u64,
|
||||
) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let dsp_state = device.dsp.state().map_err(|e| e.to_string())?;
|
||||
let dsp_state = device.dsp.state();
|
||||
|
||||
Ok(dsp_state.clone())
|
||||
})
|
||||
}
|
||||
|
||||
// Commands
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
db: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_channel_gain(channel, db)
|
||||
.map_err(|e| e.to_string())?;
|
||||
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
Ok(r)
|
||||
// Command `0x11`
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.disconnect().map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x15`
|
||||
#[tauri::command]
|
||||
pub async fn set_mute(
|
||||
pub async fn set_delay_unit(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
muted: bool,
|
||||
unit: types::DelayUnit,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_mute(channel, muted)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_inverse_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
inverted: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_channel_inverse_gain(channel, inverted)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(r)
|
||||
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x20`
|
||||
#[tauri::command]
|
||||
pub async fn recall_preset(
|
||||
state: State<'_, AppState>,
|
||||
@@ -172,16 +162,35 @@ pub async fn recall_preset(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x21` -- TODO
|
||||
#[tauri::command]
|
||||
pub async fn get_meter_levels(
|
||||
pub async fn store_current_configuration(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
) -> Result<dsp_thomann::dsp408::types::Meters, String> {
|
||||
preset_index: usize,
|
||||
force: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.get_meter_levels().map_err(|e| e.to_string())
|
||||
device
|
||||
.dsp
|
||||
.store_current_configuration(preset_index, force)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x23`
|
||||
#[tauri::command]
|
||||
pub async fn factory_reset(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
force: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.factory_reset(force).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x26`
|
||||
#[tauri::command]
|
||||
pub async fn set_current_preset_name(
|
||||
state: State<'_, AppState>,
|
||||
@@ -196,24 +205,53 @@ pub async fn set_current_preset_name(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x2A`
|
||||
#[tauri::command]
|
||||
pub async fn set_gate(
|
||||
pub async fn copy_channel(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::InputChannel,
|
||||
threshold: f32,
|
||||
attack: u16,
|
||||
hold: u16,
|
||||
release: u16,
|
||||
channel_1: types::Channel,
|
||||
channel_2: types::Channel,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_gate(channel, threshold, attack, hold, release)
|
||||
.copy_channel(channel_1, channel_2)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x2D` -- TODO
|
||||
#[tauri::command]
|
||||
pub async fn unlock_device(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
password: String,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.unlock_device(&password)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x2F`
|
||||
#[tauri::command]
|
||||
pub async fn lock_device(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
password: String,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.lock_device(&password)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x30`
|
||||
#[tauri::command]
|
||||
pub async fn set_compressor(
|
||||
state: State<'_, AppState>,
|
||||
@@ -233,112 +271,7 @@ pub async fn set_compressor(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_limiter(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::OutputChannel,
|
||||
threshold: f32,
|
||||
attack: u16,
|
||||
release: u16,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_limiter(channel, threshold, attack, release)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_delay_unit(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
unit: types::DelayUnit,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_delay(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::Channel,
|
||||
delay: f32,
|
||||
unit: DelayUnit,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_delay(channel, delay, unit)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_matrix_routing(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
output: types::OutputChannel,
|
||||
inputs: Vec<types::InputChannel>,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_matrix_routing(output, inputs)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_matrix_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
output_channel: types::OutputChannel,
|
||||
gain: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_matrix_gain(input_channel, output_channel, gain)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_geq_band(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
frequency_band: types::DiscreteFrequency,
|
||||
gain: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_geq_band(input_channel, frequency_band, gain)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_geq_bypass(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
bypass: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_geq_bypass(input_channel, bypass)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x31`
|
||||
#[tauri::command]
|
||||
pub async fn set_low_pass(
|
||||
state: State<'_, AppState>,
|
||||
@@ -355,6 +288,7 @@ pub async fn set_low_pass(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x32`
|
||||
#[tauri::command]
|
||||
pub async fn set_high_pass(
|
||||
state: State<'_, AppState>,
|
||||
@@ -371,6 +305,7 @@ pub async fn set_high_pass(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x33`
|
||||
#[tauri::command]
|
||||
pub async fn set_peq_band(
|
||||
state: State<'_, AppState>,
|
||||
@@ -391,47 +326,78 @@ pub async fn set_peq_band(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x34`
|
||||
#[tauri::command]
|
||||
pub async fn store_current_configuration(
|
||||
pub async fn set_channel_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
preset_index: usize,
|
||||
force: bool,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
db: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_channel_gain(channel, db)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x35`
|
||||
#[tauri::command]
|
||||
pub async fn set_mute(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
muted: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_mute(channel, muted)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x36`
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_inverse_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: dsp_thomann::dsp408::types::Channel,
|
||||
inverted: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
let r = device
|
||||
.dsp
|
||||
.set_channel_inverse_gain(channel, inverted)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x38`
|
||||
#[tauri::command]
|
||||
pub async fn set_delay(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::Channel,
|
||||
delay: f32,
|
||||
unit: DelayUnit,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.store_current_configuration(preset_index, force)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn factory_reset(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
force: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.factory_reset(force).map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn copy_channel(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel_1: types::Channel,
|
||||
channel_2: types::Channel,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.copy_channel(channel_1, channel_2)
|
||||
.set_delay(channel, delay, unit)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x39`
|
||||
#[tauri::command]
|
||||
pub async fn set_input_source(
|
||||
state: State<'_, AppState>,
|
||||
@@ -446,6 +412,23 @@ pub async fn set_input_source(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3A`
|
||||
#[tauri::command]
|
||||
pub async fn set_matrix_routing(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
output: types::OutputChannel,
|
||||
inputs: Vec<types::InputChannel>,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_matrix_routing(output, inputs)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3B`
|
||||
#[tauri::command]
|
||||
pub async fn link_channels(
|
||||
state: State<'_, AppState>,
|
||||
@@ -461,6 +444,7 @@ pub async fn link_channels(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3C`
|
||||
#[tauri::command]
|
||||
pub async fn set_bypass_peq(
|
||||
state: State<'_, AppState>,
|
||||
@@ -476,6 +460,7 @@ pub async fn set_bypass_peq(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3D` -- TODO
|
||||
#[tauri::command]
|
||||
pub async fn set_channel_name(
|
||||
state: State<'_, AppState>,
|
||||
@@ -490,3 +475,101 @@ pub async fn set_channel_name(
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3E`
|
||||
#[tauri::command]
|
||||
pub async fn set_gate(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::InputChannel,
|
||||
threshold: f32,
|
||||
attack: u16,
|
||||
hold: u16,
|
||||
release: u16,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_gate(channel, threshold, attack, hold, release)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x3F`
|
||||
#[tauri::command]
|
||||
pub async fn set_limiter(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
channel: types::OutputChannel,
|
||||
threshold: f32,
|
||||
attack: u16,
|
||||
release: u16,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_limiter(channel, threshold, attack, release)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x40`
|
||||
#[tauri::command]
|
||||
pub async fn get_meter_levels(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
) -> Result<(types::InputMeters, types::OutputMeters), String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device.dsp.get_meter_levels().map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x41`
|
||||
#[tauri::command]
|
||||
pub async fn set_matrix_gain(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
output_channel: types::OutputChannel,
|
||||
gain: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_matrix_gain(input_channel, output_channel, gain)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x48`
|
||||
#[tauri::command]
|
||||
pub async fn set_geq_band(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
frequency_band: types::DiscreteFrequency,
|
||||
gain: f32,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_geq_band(input_channel, frequency_band, gain)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x49`
|
||||
#[tauri::command]
|
||||
pub async fn set_geq_bypass(
|
||||
state: State<'_, AppState>,
|
||||
id: u64,
|
||||
input_channel: types::InputChannel,
|
||||
bypass: bool,
|
||||
) -> Result<bool, String> {
|
||||
state.with_device_mut(id, |device| {
|
||||
device
|
||||
.dsp
|
||||
.set_geq_bypass(input_channel, bypass)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::create_dsp408,
|
||||
commands::remove_dsp408,
|
||||
commands::get_dsp_snapshot,
|
||||
commands::get_dsp_state,
|
||||
commands::connect_dsp408, // 0x10
|
||||
commands::disconnect_dsp408, // 0x11
|
||||
@@ -20,6 +21,8 @@ pub fn run() {
|
||||
commands::factory_reset, // 0x23
|
||||
commands::set_current_preset_name, // 0x26
|
||||
commands::copy_channel, // 0x2A
|
||||
commands::unlock_device, // Command `0x2D`
|
||||
commands::lock_device, // Command `0x2F`
|
||||
commands::set_compressor, // 0x30
|
||||
commands::set_low_pass, // 0x31
|
||||
commands::set_high_pass, // 0x32
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "thomann-dsp-app",
|
||||
"title": "Thomann DSP Controller",
|
||||
"width": 1600,
|
||||
"height": 950,
|
||||
"minWidth": 1200,
|
||||
@@ -27,11 +27,11 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
"./icons/32x32.png",
|
||||
"./icons/128x128.png",
|
||||
"./icons/128x128@2x.png",
|
||||
"./icons/icon.icns",
|
||||
"./icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './App.css';
|
||||
import { DSP, AppPage } from './types';
|
||||
import { DSP, AppPage, isConnected } from './types';
|
||||
import TopBar from './components/Topbar/Topbar';
|
||||
import DSPPage from './pages/DspPage/DspPage';
|
||||
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
||||
@@ -17,7 +17,7 @@ function App() {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [page, setPage] = useState<AppPage>('home');
|
||||
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
||||
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
|
||||
const connectedDspCount = dsps.filter((dsp) => isConnected(dsp)).length;
|
||||
|
||||
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
||||
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
|
||||
|
||||
@@ -84,3 +84,152 @@ export function TextIcon({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
|
||||
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
|
||||
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 12H7M17 12H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 4.5L17 12L7 19.5V4.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21H12L20 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L11 13L21 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L11 8H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M4 5V19M12 8.5V15.5M20 11V13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3 7C11 7 11 4 21 4" />
|
||||
<path d="M3 7C11 7 11 20 21 20" />
|
||||
<path d="M3 17C11 17 11 12 21 12" />
|
||||
<circle cx="3" cy="7" r="1.6" fill="none" />
|
||||
<circle cx="3" cy="17" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="4" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="12" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="20" r="1.6" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3L21 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M8 3L13 10H3L8 3Z"
|
||||
d="M8 3L2 12H14L8 3Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="transparent"
|
||||
@@ -16,7 +16,7 @@ export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M8 13L3 6H13L8 13Z"
|
||||
d="M8 13L2 4H14L8 13Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="transparent"
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
min-height: var(--card-control-height);
|
||||
|
||||
padding: 0 var(--space-4);
|
||||
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-md);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -26,27 +24,54 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Variants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-brand);
|
||||
color: var(--bg-void);
|
||||
}
|
||||
|
||||
/* Ghost + Toggle */
|
||||
.btn-ghost,
|
||||
.btn-toggle,
|
||||
.btn-outline {
|
||||
.btn-toggle {
|
||||
background: transparent;
|
||||
border-color: var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Outline */
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--accent-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hover */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-brand) 95%, white);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled),
|
||||
.btn-toggle:hover:not(:disabled),
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
.btn-toggle:hover:not(:disabled) {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active toggle states */
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Active toggle states */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.btn-toggle.active-info {
|
||||
background: var(--accent-brand);
|
||||
border-color: var(--accent-brand);
|
||||
@@ -65,7 +90,10 @@
|
||||
color: var(--bg-void);
|
||||
}
|
||||
|
||||
/* Optional: active state hover */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Active toggle hover */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.btn-toggle.active-info:hover:not(:disabled) {
|
||||
background: var(--accent-brand);
|
||||
border-color: var(--accent-brand);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
.channel-group {
|
||||
position: relative;
|
||||
/* flex: 1 1 0; */
|
||||
min-height: 0;
|
||||
|
||||
flex: 0 0 auto;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
padding: 6cqh var(--space-3) 1cqh;
|
||||
margin-bottom: 1cqh;
|
||||
margin-top: 1cqh;
|
||||
|
||||
box-sizing: border-box;
|
||||
overflow: visible; /* in case something upstream set hidden */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.channel-group__label {
|
||||
@@ -36,6 +39,9 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
|
||||
width: fit-content;
|
||||
height: 100%;
|
||||
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/* ChoiceSelect.css */
|
||||
.choice-select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.choice-select[data-disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.choice-select__trigger {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 0;
|
||||
padding-inline: 5%;
|
||||
|
||||
background: transparent;
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 90%;
|
||||
|
||||
color: inherit;
|
||||
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.choice-select__trigger:hover,
|
||||
.choice-select__trigger--open {
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.choice-select__trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.choice-select__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.choice-select__chevron {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 4cqh;
|
||||
aspect-ratio: 1;
|
||||
|
||||
color: var(--accent-brand);
|
||||
transition: transform var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.choice-select__trigger--open .choice-select__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.choice-select__chevron svg {
|
||||
width: 50%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.choice-select__option {
|
||||
padding: 1%;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 100%;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.choice-select__option--highlight {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.choice-select__option--selected {
|
||||
color: var(--accent-brand);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.choice-select__listbox {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
|
||||
top: calc(100% + var(--space-1));
|
||||
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
list-style: none;
|
||||
|
||||
background: var(--bg-panel);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.choice-select__listbox--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-1));
|
||||
}
|
||||
|
||||
.choice-select__listbox--portal {
|
||||
position: fixed;
|
||||
z-index: 1000; /* clear of any local stacking contexts in the panel */
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// ChoiceSelect.tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||
import './ChoiceSelect.css';
|
||||
|
||||
type ChoiceSelectProps<T extends string> = {
|
||||
value: T;
|
||||
options: readonly T[];
|
||||
formatLabel?: (value: T) => string;
|
||||
onChange: (value: T) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height
|
||||
const LISTBOX_GAP = 4; // px, ~= var(--space-1)
|
||||
|
||||
type Coords = { top: number; left: number; width: number; fontSize: number };
|
||||
|
||||
function ChoiceSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
formatLabel = (v) => v,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
className = '',
|
||||
style,
|
||||
}: ChoiceSelectProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [dropUp, setDropUp] = useState(false);
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listboxRef = useRef<HTMLUListElement>(null);
|
||||
const suppressNextClick = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
const clickedOutside =
|
||||
rootRef.current &&
|
||||
!rootRef.current.contains(target) &&
|
||||
!listboxRef.current?.contains(target);
|
||||
|
||||
if (clickedOutside && open) {
|
||||
setOpen(false);
|
||||
suppressNextClick.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickCapture(e: MouseEvent) {
|
||||
if (suppressNextClick.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressNextClick.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('click', handleClickCapture, true); // capture phase
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('click', handleClickCapture, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Re-check placement/position on scroll/resize while open — the trigger's
|
||||
// position can change (e.g. inside a scrollable panel), and since the
|
||||
// listbox is portaled it won't move with it automatically.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const recalc = () => updatePlacement();
|
||||
window.addEventListener('scroll', recalc, true);
|
||||
window.addEventListener('resize', recalc);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', recalc, true);
|
||||
window.removeEventListener('resize', recalc);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const step = (direction: 1 | -1) => {
|
||||
const currentIndex = options.indexOf(value);
|
||||
if (currentIndex === -1 || options.length === 0) return;
|
||||
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
||||
onChange(options[nextIndex]);
|
||||
};
|
||||
|
||||
function updatePlacement() {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
|
||||
setDropUp(needsFlip);
|
||||
|
||||
// Capture the trigger's resolved font-size (its cqh has already resolved
|
||||
// correctly here, since it's still inside the real container tree) and
|
||||
// carry it as a plain px value into the portal, where cqh would break.
|
||||
const fontSize = parseFloat(getComputedStyle(el).fontSize);
|
||||
|
||||
setCoords({
|
||||
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
function openList() {
|
||||
if (disabled) return;
|
||||
updatePlacement();
|
||||
setOpen(true);
|
||||
const idx = options.indexOf(value);
|
||||
setHighlight(idx >= 0 ? idx : 0);
|
||||
}
|
||||
|
||||
function pick(opt: T) {
|
||||
if (opt !== value) onChange(opt);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
if (!open) {
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
openList();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.min(h + 1, options.length - 1));
|
||||
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(h - 1, 0));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
pick(options[highlight]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`choice-select ${className}`}
|
||||
data-disabled={disabled || undefined}
|
||||
ref={rootRef}
|
||||
style={style}
|
||||
>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
ref={triggerRef}
|
||||
className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
|
||||
onClick={() => (open ? setOpen(false) : openList())}
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={(e) => {
|
||||
e.preventDefault();
|
||||
if (disabled) return;
|
||||
if (e.deltaY < 0) step(-1);
|
||||
else if (e.deltaY > 0) step(1);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="choice-select__label">{formatLabel(value)}</span>
|
||||
<span className="choice-select__chevron" aria-hidden="true">
|
||||
<TriangleDownIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={listboxRef}
|
||||
className={
|
||||
'choice-select__listbox choice-select__listbox--portal' +
|
||||
(dropUp ? ' choice-select__listbox--up' : '')
|
||||
}
|
||||
role="listbox"
|
||||
style={{
|
||||
fontSize: coords.fontSize,
|
||||
left: coords.left,
|
||||
width: coords.width,
|
||||
// dropUp needs top: auto + bottom instead, see CSS note below
|
||||
...(dropUp
|
||||
? { top: 'auto', bottom: window.innerHeight - coords.top }
|
||||
: { top: coords.top }),
|
||||
}}
|
||||
>
|
||||
{options.map((option, i) => (
|
||||
<li
|
||||
key={option}
|
||||
role="option"
|
||||
aria-selected={option === value}
|
||||
className={
|
||||
'choice-select__option' +
|
||||
(option === value ? ' choice-select__option--selected' : '') +
|
||||
(i === highlight ? ' choice-select__option--highlight' : '')
|
||||
}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
onClick={() => pick(option)}
|
||||
>
|
||||
{formatLabel(option)}
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChoiceSelect;
|
||||
@@ -5,7 +5,6 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -17,17 +16,14 @@
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 3cqh;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font--sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-align: left;
|
||||
|
||||
color: var(--accent-brand);
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5ch;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -40,20 +36,21 @@
|
||||
|
||||
.choice-stepper__arrows {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.5cqh;
|
||||
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1cqh;
|
||||
padding-left: 0;
|
||||
|
||||
padding-right: 1%;
|
||||
}
|
||||
|
||||
.choice-stepper__arrow-btn {
|
||||
aspect-ratio: 1;
|
||||
height: 4.5cqh;
|
||||
height: 50%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
display: flex;
|
||||
@@ -61,10 +58,11 @@
|
||||
justify-content: center;
|
||||
|
||||
padding: 0;
|
||||
color: var(--accent-brand);
|
||||
|
||||
color: var(--accent-brand);
|
||||
background: transparent;
|
||||
border-radius: 1cqh;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -75,6 +73,20 @@
|
||||
}
|
||||
|
||||
.choice-stepper__arrow-btn svg {
|
||||
width: 100%;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.choice-stepper[data-disabled] {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.choice-stepper__input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.choice-stepper[data-disabled] .choice-stepper__input,
|
||||
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
|
||||
color: var(--border-hairline);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { JSX, useEffect, useRef, useState } from 'react';
|
||||
import Button from '../Button/Button';
|
||||
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||
import './ChoiceStepper.css';
|
||||
|
||||
type ChoiceStepperProps<T> = {
|
||||
const WHEEL_COMMIT_DELAY = 250;
|
||||
// Safety net: if the parent's `value` prop never comes back exactly equal
|
||||
// to what we committed (rounding, an intermediate transform, etc.), stop
|
||||
// waiting after this long so the stepper doesn't get stuck ignoring
|
||||
// external updates.
|
||||
const PENDING_FALLBACK_MS = 1000;
|
||||
|
||||
type CommitResult = Promise<boolean | void> | boolean | void;
|
||||
|
||||
function useNonPassiveWheel(
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
handler: (e: WheelEvent) => void
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const listener = (e: WheelEvent) => handlerRef.current(e);
|
||||
el.addEventListener('wheel', listener, { passive: false });
|
||||
return () => el.removeEventListener('wheel', listener);
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
function StepperArrows({
|
||||
onUp,
|
||||
onDown,
|
||||
canUp,
|
||||
canDown,
|
||||
disabled,
|
||||
}: {
|
||||
onUp: () => void;
|
||||
onDown: () => void;
|
||||
canUp: boolean;
|
||||
canDown: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="choice-stepper__arrows">
|
||||
<Button
|
||||
variant="toggle"
|
||||
className="choice-stepper__arrow-btn"
|
||||
onClick={onUp}
|
||||
disabled={disabled || !canUp}
|
||||
>
|
||||
<TriangleUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="toggle"
|
||||
className="choice-stepper__arrow-btn"
|
||||
onClick={onDown}
|
||||
disabled={disabled || !canDown}
|
||||
>
|
||||
<TriangleDownIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- List variant ----
|
||||
|
||||
type ChoiceStepperListProps<T> = {
|
||||
value: T;
|
||||
values: readonly T[];
|
||||
formatLabel: (value: T) => string;
|
||||
onChange: (value: T) => void;
|
||||
onChange: (value: T) => CommitResult;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ChoiceStepper<T>({ value, values, formatLabel, onChange }: ChoiceStepperProps<T>) {
|
||||
const index = values.indexOf(value);
|
||||
|
||||
const canUp = index === -1 || index < values.length - 1;
|
||||
const canDown = index > 0;
|
||||
|
||||
function ChoiceStepperList<T>({
|
||||
value,
|
||||
values,
|
||||
formatLabel,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
className,
|
||||
}: ChoiceStepperListProps<T>) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [text, setText] = useState(formatLabel(value));
|
||||
|
||||
// True from the moment a commit starts until the prop `value` we get from
|
||||
// the parent actually reflects it (not just until onChange resolves —
|
||||
// those are two different moments and the gap between them is what was
|
||||
// causing the old-value blink).
|
||||
const interacting = useRef(false);
|
||||
// The value we're waiting to see echoed back via the `value` prop.
|
||||
const pendingValue = useRef<T | null>(null);
|
||||
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useNonPassiveWheel(inputRef, (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0 && canUp) {
|
||||
stepWheel(1);
|
||||
} else if (e.deltaY > 0 && canDown) {
|
||||
stepWheel(-1);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (interacting.current) {
|
||||
// Only release control once the prop actually catches up to what we
|
||||
// committed — otherwise keep showing localValue as-is (no flash back
|
||||
// to the stale old value in between).
|
||||
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
}, [value, formatLabel]);
|
||||
|
||||
const step = (direction: 1 | -1) => {
|
||||
const currentIndex = index === -1 ? 0 : index;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + direction));
|
||||
const index = values.indexOf(localValue);
|
||||
const canUp = index === -1 || index < values.length - 1;
|
||||
const canDown = index > 0;
|
||||
|
||||
if (nextIndex !== currentIndex) {
|
||||
onChange(values[nextIndex]);
|
||||
const stepLocal = (direction: 1 | -1): T | null => {
|
||||
const currentIndex = values.indexOf(localValue);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= values.length) return null;
|
||||
|
||||
const next = values[nextIndex];
|
||||
setLocalValue(next);
|
||||
setText(formatLabel(next));
|
||||
return next;
|
||||
};
|
||||
|
||||
async function commit(next: T) {
|
||||
interacting.current = true;
|
||||
pendingValue.current = next;
|
||||
|
||||
const result = await onChange(next);
|
||||
|
||||
if (result === false) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
return;
|
||||
}
|
||||
|
||||
// Accepted: don't clear `interacting` here. Wait for the prop-sync
|
||||
// effect above to see `value === next` and release it, so we never
|
||||
// hand control back to a still-stale prop. Guard with a timeout in
|
||||
// case it never matches exactly.
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
fallbackTimeout.current = setTimeout(() => {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
}, PENDING_FALLBACK_MS);
|
||||
}
|
||||
|
||||
const stepAndCommit = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next !== null) void commit(next);
|
||||
};
|
||||
|
||||
const stepWheel = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next === null) return;
|
||||
|
||||
interacting.current = true;
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
wheelTimeout.current = setTimeout(() => {
|
||||
wheelTimeout.current = null;
|
||||
void commit(next);
|
||||
}, WHEEL_COMMIT_DELAY);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
const match = values.find(
|
||||
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
||||
);
|
||||
|
||||
if (match !== undefined) {
|
||||
onChange(match);
|
||||
setLocalValue(match);
|
||||
void commit(match);
|
||||
} else {
|
||||
setText(formatLabel(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="choice-stepper">
|
||||
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="choice-stepper__input"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(e) => {
|
||||
@@ -56,35 +220,208 @@ function ChoiceStepper<T>({ value, values, formatLabel, onChange }: ChoiceSteppe
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
step(1);
|
||||
interacting.current = true;
|
||||
stepLocal(1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
step(-1);
|
||||
interacting.current = true;
|
||||
stepLocal(-1);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
void commit(localValue);
|
||||
}}
|
||||
/>
|
||||
<StepperArrows
|
||||
onUp={() => stepAndCommit(1)}
|
||||
onDown={() => stepAndCommit(-1)}
|
||||
canUp={canUp}
|
||||
canDown={canDown}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="choice-stepper__arrows">
|
||||
<Button
|
||||
variant="toggle"
|
||||
className="choice-stepper__arrow-btn"
|
||||
onClick={() => step(1)}
|
||||
disabled={!canUp}
|
||||
>
|
||||
<TriangleUpIcon />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="toggle"
|
||||
className="choice-stepper__arrow-btn"
|
||||
onClick={() => step(-1)}
|
||||
disabled={!canDown}
|
||||
>
|
||||
<TriangleDownIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Range variant (concrete, no generic) ----
|
||||
|
||||
type ChoiceStepperRangeProps = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
formatLabel: (value: number) => string;
|
||||
onChange: (value: number) => CommitResult;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ChoiceStepperRange({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step: stepSize = 1,
|
||||
formatLabel,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
className,
|
||||
}: ChoiceStepperRangeProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [text, setText] = useState(formatLabel(value));
|
||||
|
||||
const interacting = useRef(false);
|
||||
const pendingValue = useRef<number | null>(null);
|
||||
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useNonPassiveWheel(inputRef, (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaY < 0 && canUp) {
|
||||
stepWheel(1);
|
||||
} else if (e.deltaY > 0 && canDown) {
|
||||
stepWheel(-1);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (interacting.current) {
|
||||
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
}, [value, formatLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
||||
|
||||
const canUp = localValue < max;
|
||||
const canDown = localValue > min;
|
||||
|
||||
const stepLocal = (direction: 1 | -1): number | null => {
|
||||
const next = clamp(localValue + direction * stepSize);
|
||||
if (next === localValue) return null;
|
||||
setLocalValue(next);
|
||||
setText(formatLabel(next));
|
||||
return next;
|
||||
};
|
||||
|
||||
async function commit(next: number) {
|
||||
interacting.current = true;
|
||||
pendingValue.current = next;
|
||||
|
||||
const result = await onChange(next);
|
||||
|
||||
if (result === false) {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
setLocalValue(value);
|
||||
setText(formatLabel(value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||
fallbackTimeout.current = setTimeout(() => {
|
||||
interacting.current = false;
|
||||
pendingValue.current = null;
|
||||
}, PENDING_FALLBACK_MS);
|
||||
}
|
||||
|
||||
const stepAndCommit = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next !== null) void commit(next);
|
||||
};
|
||||
|
||||
const stepWheel = (direction: 1 | -1) => {
|
||||
const next = stepLocal(direction);
|
||||
if (next === null) return;
|
||||
|
||||
interacting.current = true;
|
||||
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||
wheelTimeout.current = setTimeout(() => {
|
||||
wheelTimeout.current = null;
|
||||
void commit(next);
|
||||
}, WHEEL_COMMIT_DELAY);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
const parsed = Number(text);
|
||||
if (Number.isFinite(parsed)) {
|
||||
const next = clamp(parsed);
|
||||
setLocalValue(next);
|
||||
void commit(next);
|
||||
} else {
|
||||
setText(formatLabel(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="choice-stepper__input"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (canUp) {
|
||||
interacting.current = true;
|
||||
stepLocal(1);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (canDown) {
|
||||
interacting.current = true;
|
||||
stepLocal(-1);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
void commit(localValue);
|
||||
}}
|
||||
/>
|
||||
<StepperArrows
|
||||
onUp={() => stepAndCommit(1)}
|
||||
onDown={() => stepAndCommit(-1)}
|
||||
canUp={canUp}
|
||||
canDown={canDown}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
|
||||
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
|
||||
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
|
||||
if ('values' in props) {
|
||||
return <ChoiceStepperList {...props} />;
|
||||
}
|
||||
return <ChoiceStepperRange {...props} />;
|
||||
}
|
||||
|
||||
export default ChoiceStepper;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface LinearGraphProps {
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
yStep?: number;
|
||||
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */
|
||||
/** 'log' requires xMin > 0. */
|
||||
xScale?: AxisScale;
|
||||
/** 'log' requires yMin > 0. */
|
||||
yScale?: AxisScale;
|
||||
@@ -55,7 +55,7 @@ export interface LinearGraphProps {
|
||||
const defaultFormat = (v: number) => {
|
||||
if (Math.abs(v) < 1e-9) return '0';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000 || abs < 0.01) return v.toExponential(1);
|
||||
if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
|
||||
return parseFloat(v.toFixed(4)).toString();
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
const topBase = cq.h * 3;
|
||||
const rightBase = cq.w * 5;
|
||||
const bottomBase = cq.h * 4;
|
||||
const leftBase = cq.w * 2;
|
||||
const leftBase = cq.w * 7;
|
||||
const xLabelSpace = xLabel ? cq.h * 7 : 0;
|
||||
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
|
||||
|
||||
@@ -221,7 +221,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const plot = {
|
||||
x: pad.left + cq.w * 2,
|
||||
x: pad.left,
|
||||
y: pad.top,
|
||||
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
|
||||
height: Math.max(1, cssH - pad.top - pad.bottom),
|
||||
@@ -271,14 +271,14 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
||||
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
|
||||
}
|
||||
|
||||
const clipPad = 2;
|
||||
const borderInset = 1; // Avoid clipping outside graph bounds
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(
|
||||
plot.x - clipPad,
|
||||
plot.y - clipPad,
|
||||
plot.width + clipPad * 2,
|
||||
plot.height + clipPad * 2
|
||||
plot.x + borderInset,
|
||||
plot.y + borderInset,
|
||||
plot.width - borderInset * 2,
|
||||
plot.height - borderInset * 2
|
||||
);
|
||||
ctx.clip();
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
flex-direction: column;
|
||||
align-items: left;
|
||||
gap: 0.3cqh;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.meter-bar__title {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.notifications {
|
||||
position: fixed;
|
||||
right: var(--space-5);
|
||||
bottom: var(--space-5);
|
||||
top: var(--space-5);
|
||||
z-index: 9999;
|
||||
|
||||
display: flex;
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
overflow: visible;
|
||||
|
||||
transition: width var(--dur-base) var(--ease-standard);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-rail--expanded {
|
||||
@@ -32,13 +35,14 @@
|
||||
justify-content: center;
|
||||
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
.sidebar-rail--expanded .sidebar-header {
|
||||
justify-content: space-between;
|
||||
padding-inline: var(--space-3);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@@ -361,3 +365,54 @@
|
||||
.app-root--sidebar-expanded .stage--with-sidebar {
|
||||
margin-left: var(--sidebar-width-expanded);
|
||||
}
|
||||
|
||||
/* ---------- Item (editing state) ---------- */
|
||||
|
||||
.sidebar-item--editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sidebar-item-gap);
|
||||
|
||||
aspect-ratio: 1;
|
||||
height: calc(var(--header-height) * 0.7);
|
||||
|
||||
border: none;
|
||||
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-input {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
text-align: center;
|
||||
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
|
||||
transition:
|
||||
opacity var(--dur-fast) var(--ease-standard),
|
||||
max-width var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sidebar-rail--expanded .sidebar-item-input {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
max-width: 200px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SidebarItem<T extends string = string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
export type SidebarAppOption = {
|
||||
@@ -17,11 +18,75 @@ function SidebarButton<T extends string>({
|
||||
item,
|
||||
active,
|
||||
onSelect,
|
||||
onRename,
|
||||
}: {
|
||||
item: SidebarItem<T>;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onRename?: (name: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(item.label);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setInputValue(item.label);
|
||||
}, [item.label, editing]);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!onRename) return;
|
||||
|
||||
setInputValue(item.label);
|
||||
setEditing(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
setEditing(false);
|
||||
|
||||
const trimmed = inputValue.trim();
|
||||
|
||||
if (!trimmed || trimmed === item.label) {
|
||||
setInputValue(item.label);
|
||||
return;
|
||||
}
|
||||
|
||||
onRename?.(trimmed);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') inputRef.current?.blur();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setInputValue(item.label);
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="sidebar-item sidebar-item--editing">
|
||||
<span className="sidebar-icon">{item.icon}</span>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sidebar-item-input"
|
||||
value={inputValue}
|
||||
maxLength={8}
|
||||
onChange={(e) => setInputValue(e.target.value.slice(0, 8))}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Rename channel"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
@@ -31,7 +96,14 @@ function SidebarButton<T extends string>({
|
||||
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
|
||||
>
|
||||
<span className="sidebar-icon">{item.icon}</span>
|
||||
<span className="sidebar-label">{item.label}</span>
|
||||
|
||||
<span
|
||||
className="sidebar-label"
|
||||
onDoubleClick={onRename ? startEditing : undefined}
|
||||
title={onRename ? `${item.label} (double click to rename)` : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -46,8 +118,8 @@ function AppCombobox({
|
||||
appOptions: SidebarAppOption[];
|
||||
selectedAppId?: string;
|
||||
appName: string;
|
||||
onAppChange?: (id: string) => void;
|
||||
onAppRename?: (name: string) => void;
|
||||
onAppChange: (id: string) => void;
|
||||
onAppRename: (name: string) => void;
|
||||
}) {
|
||||
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
|
||||
const canRename = currentOption?.id !== 'f00';
|
||||
@@ -95,6 +167,7 @@ function AppCombobox({
|
||||
|
||||
if (!canRename) return;
|
||||
|
||||
setInputValue((currentOption?.name ?? appName).trim());
|
||||
setEditing(true);
|
||||
setOpen(false);
|
||||
|
||||
@@ -124,13 +197,13 @@ function AppCombobox({
|
||||
|
||||
if (matched) {
|
||||
if (matched.id !== selectedAppId) {
|
||||
onAppChange?.(matched.id);
|
||||
onAppChange(matched.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed !== currentOption?.name) {
|
||||
onAppRename?.(trimmed);
|
||||
onAppRename(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +259,7 @@ function AppCombobox({
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
title={canRename ? 'Double click to rename' : undefined}
|
||||
>
|
||||
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
|
||||
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
|
||||
@@ -209,7 +283,8 @@ function AppCombobox({
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
onClick={() => pick(opt)}
|
||||
>
|
||||
{opt.name}
|
||||
<span className="sidebar-app-option-id">{opt.id + '-'}</span>
|
||||
<span>{opt.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -227,6 +302,7 @@ export function Sidebar<T extends string>({
|
||||
selectedAppId,
|
||||
onAppChange,
|
||||
onAppRename,
|
||||
onItemRename,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -236,8 +312,9 @@ export function Sidebar<T extends string>({
|
||||
appName: string;
|
||||
appOptions?: SidebarAppOption[];
|
||||
selectedAppId?: string;
|
||||
onAppChange?: (id: string) => void;
|
||||
onAppRename?: (name: string) => void;
|
||||
onAppChange: (id: string) => void;
|
||||
onAppRename: (name: string) => void;
|
||||
onItemRename?: (id: T, name: string) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
@@ -295,6 +372,9 @@ export function Sidebar<T extends string>({
|
||||
item={item}
|
||||
active={activeId === item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
onRename={
|
||||
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
|
||||
import { DSP408 } from '../../types';
|
||||
import {
|
||||
@@ -69,6 +69,14 @@ const MARGIN = 36;
|
||||
const LEFT_CHAIN_W = 560;
|
||||
const RIGHT_CHAIN_W = 620;
|
||||
|
||||
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
|
||||
// user units. Much wider than the visible 1–2.5px line so it's easy to click
|
||||
// or catch with a scroll wheel without needing pixel-perfect aim.
|
||||
const LINE_HITBOX_WIDTH = 14;
|
||||
|
||||
// How long the gain tooltip stays visible after the last wheel tick, in ms.
|
||||
const GAIN_TOOLTIP_HOLD_MS = 700;
|
||||
|
||||
function getRoutes(dsp: DSP408, output: OutputChannel) {
|
||||
return dsp.state.current_config.output_states[output].matrix_routes;
|
||||
}
|
||||
@@ -79,16 +87,47 @@ function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
||||
return getRoutes(dsp, output).gains[input];
|
||||
}
|
||||
|
||||
function formatGainDb(value: number) {
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(1)} dB`;
|
||||
}
|
||||
|
||||
export function useNonPassiveWheel(
|
||||
ref: React.RefObject<Element | null>,
|
||||
handler: (e: WheelEvent) => void
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
|
||||
el.addEventListener('wheel', listener, { passive: false });
|
||||
return () => el.removeEventListener('wheel', listener);
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
type GainTooltipState = {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function SignalFlowMatrix({
|
||||
dsp,
|
||||
setMatrixRouting,
|
||||
setMatrixGain,
|
||||
setActiveSidebar,
|
||||
onGainPreview,
|
||||
}: {
|
||||
dsp: DSP408;
|
||||
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
|
||||
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
|
||||
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
|
||||
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
|
||||
}) {
|
||||
const nInputs = INPUT_ORDER.length;
|
||||
const nOutputs = OUTPUT_ORDER.length;
|
||||
@@ -109,6 +148,27 @@ function SignalFlowMatrix({
|
||||
const inputNodeX = leftStart + LEFT_CHAIN_W;
|
||||
const outputNodeX = rightStart;
|
||||
|
||||
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
label: '',
|
||||
});
|
||||
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const WHEEL_COMMIT_DELAY = 250;
|
||||
|
||||
// key = `${input}-${output}`
|
||||
const pendingGains = useRef<Map<string, number>>(new Map());
|
||||
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||
commitTimers.current.forEach(clearTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
||||
const routes = getRoutes(dsp, output);
|
||||
const next = routes.connected.includes(input)
|
||||
@@ -117,15 +177,55 @@ function SignalFlowMatrix({
|
||||
void setMatrixRouting(output, next);
|
||||
};
|
||||
|
||||
const handleWheelGain = (e: React.WheelEvent, output: OutputChannel, input: InputChannel) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
|
||||
e.preventDefault();
|
||||
const current = getGain(dsp, output, input);
|
||||
|
||||
const key = `${input}-${output}`;
|
||||
// Base on the pending value if we're mid-wheel, otherwise the real state
|
||||
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
|
||||
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
|
||||
const next =
|
||||
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
|
||||
void setMatrixGain(input, output, next);
|
||||
|
||||
pendingGains.current.set(key, next);
|
||||
onGainPreview(input, output, next);
|
||||
|
||||
// Debounced send: only the final value goes out once the wheel pauses
|
||||
const existing = commitTimers.current.get(key);
|
||||
if (existing) clearTimeout(existing);
|
||||
commitTimers.current.set(
|
||||
key,
|
||||
setTimeout(async () => {
|
||||
commitTimers.current.delete(key);
|
||||
const value = pendingGains.current.get(key);
|
||||
if (value === undefined) return;
|
||||
await setMatrixGain(input, output, value);
|
||||
// Only release if the user hasn't wheeled again while we were awaiting
|
||||
if (pendingGains.current.get(key) === value) {
|
||||
pendingGains.current.delete(key);
|
||||
onGainPreview(input, output, null);
|
||||
}
|
||||
}, WHEEL_COMMIT_DELAY)
|
||||
);
|
||||
|
||||
// Tooltip (unchanged, shows the pending value)
|
||||
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
|
||||
hideTooltipTimer.current = setTimeout(() => {
|
||||
setGainTooltip((t) => ({ ...t, visible: false }));
|
||||
}, GAIN_TOOLTIP_HOLD_MS);
|
||||
};
|
||||
|
||||
useNonPassiveWheel(svgRef, (e) => {
|
||||
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
|
||||
if (!hit) return; // not over a connected line: let the page scroll normally
|
||||
const { input, output } = hit.dataset;
|
||||
if (!input || !output) return;
|
||||
handleWheelGain(e, output as OutputChannel, input as InputChannel);
|
||||
});
|
||||
|
||||
const routePairs = useMemo(() => {
|
||||
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
|
||||
OUTPUT_ORDER.forEach((output, j) => {
|
||||
@@ -134,14 +234,27 @@ function SignalFlowMatrix({
|
||||
return pairs;
|
||||
}, []);
|
||||
|
||||
// Draw dashed (unconnected) links first so connected (solid) links always
|
||||
// paint on top of them, regardless of input/output index order. This has
|
||||
// to be recomputed whenever routing changes, since isRouted reads live
|
||||
// dsp state — unlike routePairs above, which is just static index pairs.
|
||||
const orderedRoutePairs = useMemo(() => {
|
||||
return [...routePairs].sort((a, b) => {
|
||||
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
|
||||
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
|
||||
return aConnected - bConnected;
|
||||
});
|
||||
}, [routePairs, dsp]);
|
||||
|
||||
return (
|
||||
<div className="signal-flow">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="signal-flow__svg"
|
||||
>
|
||||
{routePairs.map(({ input, output, i, j }) => {
|
||||
{orderedRoutePairs.map(({ input, output, i, j }) => {
|
||||
const connected = isRouted(dsp, output, input);
|
||||
const y1 = inputCenterY(i);
|
||||
const y2 = outputCenterY(j);
|
||||
@@ -152,6 +265,22 @@ function SignalFlowMatrix({
|
||||
|
||||
return (
|
||||
<g key={`${input}-${output}`}>
|
||||
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
|
||||
the line is easy to grab without needing to hit the thin visible
|
||||
stroke exactly. Sits directly under the visible path. */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={LINE_HITBOX_WIDTH}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleToggle(output, input)}
|
||||
data-wheel-route={connected ? '' : undefined}
|
||||
data-input={input}
|
||||
data-output={output}
|
||||
/>
|
||||
{/* Visible line, purely decorative — no pointer events, so it never
|
||||
competes with the hitbox above for hover/click/wheel. */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
@@ -159,9 +288,7 @@ function SignalFlowMatrix({
|
||||
strokeWidth={connected ? 2.5 : 1}
|
||||
strokeDasharray={connected ? undefined : '4 5'}
|
||||
opacity={connected ? 0.9 : 0.35}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleToggle(output, input)}
|
||||
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -252,6 +379,30 @@ function SignalFlowMatrix({
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{gainTooltip.visible && (
|
||||
<div
|
||||
className="signal-flow__gain-tooltip"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: gainTooltip.x + 14,
|
||||
top: gainTooltip.y - 28,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 50,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
|
||||
color: 'var(--text-primary, #fff)',
|
||||
border: '1px solid var(--border-hairline, #333)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{gainTooltip.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
-webkit-app-region: drag;
|
||||
padding-inline: var(--space-3);
|
||||
gap: var(--space-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo-slot {
|
||||
display: flex;
|
||||
height: 85%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.tabstrip {
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-width: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
border-left: 1px solid var(--border-hairline);
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-hairline) transparent;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-inline: var(--space-3);
|
||||
scroll-snap-type: x proximity;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding-left: var(--space-4);
|
||||
|
||||
container-type: inline-size;
|
||||
container-name: tabstrip;
|
||||
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 0);
|
||||
mask-image: linear-gradient(to right, transparent, black 0);
|
||||
}
|
||||
|
||||
.tabstrip-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--header-height);
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tab-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: var(--border-width-active) solid transparent;
|
||||
}
|
||||
|
||||
.tab-action:hover {
|
||||
background: var(--bg-raised);
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-add {
|
||||
height: 70%;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.crown-action {
|
||||
height: 70%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
color: #facc15;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.crown-action:hover {
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.icon-crown,
|
||||
.icon-tab-add,
|
||||
.icon-tab-add-empty {
|
||||
aspect-ratio: 1/1;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.tab-add-empty {
|
||||
height: 70%;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--border-width-active) dashed var(--border-hairline);
|
||||
color: var(--text-muted);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
gap: var(--space-2);
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.tab-add-empty:hover {
|
||||
border-color: var(--accent-brand);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Tab */
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: var(--tab-height);
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-width-active) solid var(--border-hairline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-xs);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
|
||||
flex: 0 0 25%;
|
||||
max-width: 25%;
|
||||
min-width: calc(
|
||||
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
||||
var(--tab-close-size)
|
||||
);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-led {
|
||||
height: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
background: transparent;
|
||||
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
height: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
font-size: var(--tab-close-font);
|
||||
color: var(--text-muted);
|
||||
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab--active {
|
||||
background: var(--bg-panel);
|
||||
border-color: var(--accent-brand);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab--dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabstrip[data-fade-start='true'] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 24px,
|
||||
black calc(100% - 24px),
|
||||
black
|
||||
);
|
||||
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), black);
|
||||
}
|
||||
.tabstrip[data-fade-end='true'] {
|
||||
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.tabstrip[data-fade-start='true'][data-fade-end='true'] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 24px,
|
||||
black calc(100% - 24px),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 24px,
|
||||
black calc(100% - 24px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.tab[data-step='6'],
|
||||
.tab[data-step='8'],
|
||||
.tab[data-step='10'] {
|
||||
min-width: calc(
|
||||
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
||||
var(--tab-close-size)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { DSP, AppPage, isConnected } from '../../types';
|
||||
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
||||
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
||||
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
||||
import Led from '../Led/Led';
|
||||
import { CloseIcon } from '../../assets/icons/CloseIcon';
|
||||
|
||||
import './Topbar.css';
|
||||
|
||||
function getStep(count: number) {
|
||||
if (count <= 4) return 4;
|
||||
if (count <= 6) return 6;
|
||||
if (count <= 8) return 8;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function Tab({
|
||||
dsp,
|
||||
active,
|
||||
dimmed,
|
||||
step,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
actions,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
active: boolean;
|
||||
dimmed: boolean;
|
||||
step: number;
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
onConnect: (dsp: DSP) => void;
|
||||
onDisconnect: (dsp: DSP) => void;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const isDspConnected = isConnected(dsp);
|
||||
|
||||
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-step={step}
|
||||
tabIndex={0}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onSelect}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-led"
|
||||
aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onSelect();
|
||||
|
||||
if (isDspConnected) {
|
||||
onDisconnect(dsp);
|
||||
} else {
|
||||
onConnect(dsp);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Led
|
||||
status={
|
||||
dsp.isConnecting
|
||||
? 'connecting'
|
||||
: isConnected(dsp)
|
||||
? 'connected'
|
||||
: 'disconnected'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span className="tab-name" title={dsp.name}>
|
||||
{dsp.name}
|
||||
</span>
|
||||
|
||||
{actions}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
aria-label={`Remove ${dsp.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({
|
||||
dsps,
|
||||
selected,
|
||||
page,
|
||||
setPage,
|
||||
setSelect,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onAddClick,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
dsps: DSP[];
|
||||
selected: number | null;
|
||||
page: AppPage;
|
||||
setPage: (page: AppPage) => void;
|
||||
setSelect: (select: number | null) => void;
|
||||
onSelect: (id: number) => void;
|
||||
onRemove: (dsp: DSP) => void;
|
||||
onAddClick: () => void;
|
||||
onConnect: (dsp: DSP) => void;
|
||||
onDisconnect: (dsp: DSP) => void;
|
||||
}) {
|
||||
const hasTabs = dsps.length > 0;
|
||||
const tabstripRef = useRef<HTMLDivElement>(null);
|
||||
const prevCount = useRef(dsps.length);
|
||||
const step = getStep(dsps.length);
|
||||
|
||||
const updateFade = () => {
|
||||
const el = tabstripRef.current;
|
||||
if (!el) return;
|
||||
const atStart = el.scrollLeft <= 1;
|
||||
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
||||
el.dataset.fadeStart = String(!atStart);
|
||||
el.dataset.fadeEnd = String(!atEnd && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabstripRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
|
||||
el.scrollLeft += e.deltaY;
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateFade();
|
||||
const el = tabstripRef.current;
|
||||
el?.addEventListener('scroll', updateFade);
|
||||
window.addEventListener('resize', updateFade);
|
||||
return () => {
|
||||
el?.removeEventListener('scroll', updateFade);
|
||||
window.removeEventListener('resize', updateFade);
|
||||
};
|
||||
}, [dsps.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dsps.length > prevCount.current && tabstripRef.current) {
|
||||
tabstripRef.current.scrollTo({
|
||||
left: tabstripRef.current.scrollWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
prevCount.current = dsps.length;
|
||||
}, [dsps.length]);
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button
|
||||
className="logo-slot"
|
||||
onClick={() => {
|
||||
setSelect(null);
|
||||
setPage('home');
|
||||
}}
|
||||
aria-label="Go to home"
|
||||
>
|
||||
<AnimatedLogo />
|
||||
</button>
|
||||
|
||||
<div className="tabstrip" ref={tabstripRef}>
|
||||
{dsps.map((dsp) => (
|
||||
<Tab
|
||||
key={dsp.id}
|
||||
dsp={dsp}
|
||||
step={step}
|
||||
active={selected === dsp.id && !(page == 'add-dsp')}
|
||||
dimmed={page === 'add-dsp'}
|
||||
onSelect={() => onSelect(dsp.id)}
|
||||
onRemove={() => onRemove(dsp)}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tabstrip-actions">
|
||||
{hasTabs ? (
|
||||
<button className="tab-add tab-action" aria-label="Add DSP" onClick={onAddClick}>
|
||||
<PlusIcon className="icon-tab-add" />
|
||||
</button>
|
||||
) : (
|
||||
<button className="tab-add-empty tab-action" onClick={onAddClick}>
|
||||
<PlusIcon className="icon-tab-add-empty" />
|
||||
<span>Add DSP</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="crown-action tab-action"
|
||||
title="Credits & Support"
|
||||
aria-label="Credits & Support"
|
||||
onClick={() => {
|
||||
setPage('credits');
|
||||
setSelect(null);
|
||||
}}
|
||||
>
|
||||
<CrownIcon className="icon-crown" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopBar;
|
||||
@@ -9,6 +9,9 @@
|
||||
align-items: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.gauge-track {
|
||||
@@ -58,8 +61,6 @@
|
||||
height: 100%;
|
||||
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
@@ -145,3 +146,26 @@
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.gauge-viewport.is-disabled {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.gauge-viewport.is-disabled .slider {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.gauge-viewport.is-disabled .thumb {
|
||||
border-color: var(--border-hairline);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gauge-viewport.is-disabled .value {
|
||||
color: var(--border-hairline);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.value input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ type VerticalSliderProps = {
|
||||
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Greys out the slider and blocks all interaction. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const defaultStep: StepFn = () => 0.1;
|
||||
@@ -174,6 +177,7 @@ export default function VerticalSlider({
|
||||
formatValue,
|
||||
showTicks = true,
|
||||
scale = 'linear',
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
}: VerticalSliderProps) {
|
||||
@@ -240,6 +244,26 @@ export default function VerticalSlider({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
dragging.current = false;
|
||||
keyboardEditing.current = false;
|
||||
keyboardValue.current = null;
|
||||
wheelEditing.current = false;
|
||||
wheelValue.current = null;
|
||||
if (wheelCommitTimeout.current) {
|
||||
clearTimeout(wheelCommitTimeout.current);
|
||||
wheelCommitTimeout.current = null;
|
||||
}
|
||||
|
||||
setEditingText(null);
|
||||
setDisplayValue(value);
|
||||
dragValue.current = value;
|
||||
onInput?.(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const decimals = useMemo(() => {
|
||||
if (listValues) return Math.min(3, getDecimals(displayValue));
|
||||
return Math.min(3, getDecimals(step(displayValue)));
|
||||
@@ -273,6 +297,8 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (disabled) return;
|
||||
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
@@ -307,6 +333,8 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
async function handleKeyUp() {
|
||||
if (disabled) return;
|
||||
|
||||
if (keyboardValue.current == null) return;
|
||||
|
||||
const next = keyboardValue.current;
|
||||
@@ -323,6 +351,7 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
|
||||
wheelEditing.current = true;
|
||||
@@ -393,6 +422,8 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (disabled) return;
|
||||
|
||||
const target = e.currentTarget;
|
||||
dragging.current = true;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
@@ -427,6 +458,11 @@ export default function VerticalSlider({
|
||||
}
|
||||
|
||||
async function commitEditing() {
|
||||
if (disabled) {
|
||||
setEditingText(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingText !== null && editingText !== '' && editingText !== '-') {
|
||||
const parsed = Number(editingText);
|
||||
|
||||
@@ -452,7 +488,10 @@ export default function VerticalSlider({
|
||||
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
||||
|
||||
return (
|
||||
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
||||
<div
|
||||
className={['gauge-viewport', disabled && 'is-disabled', className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
||||
ref={trackRef}
|
||||
@@ -468,7 +507,12 @@ export default function VerticalSlider({
|
||||
<div
|
||||
className="slider"
|
||||
ref={sliderRef}
|
||||
tabIndex={0}
|
||||
role="slider"
|
||||
aria-disabled={disabled}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={displayValue}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPointerDown={startDrag}
|
||||
@@ -494,6 +538,7 @@ export default function VerticalSlider({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
disabled={disabled}
|
||||
value={editingText ?? formattedValue}
|
||||
style={{ width: '100%' }}
|
||||
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* InputSection.css */
|
||||
.input-graph-section {
|
||||
.channel-graph-section {
|
||||
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
||||
min-height: 0;
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.input-graph-panel {
|
||||
.channel-graph-panel {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -24,7 +28,28 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.input-graph__title {
|
||||
.channel-bypass-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.channel-bypass-overlay span {
|
||||
font-size: 8cqh;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
color: #f87171;
|
||||
text-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(-8deg);
|
||||
opacity: 0.85;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.channel-graph__title {
|
||||
flex: 0 0 auto;
|
||||
|
||||
text-align: center;
|
||||
@@ -35,12 +60,15 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.input-graph-panel > .container {
|
||||
.channel-graph-panel > .container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.input-control-section {
|
||||
/* Bottom control bar — same pattern as .compressor-section: absolute,
|
||||
bottom-anchored, panels flow in a row and scroll horizontally instead
|
||||
of squeezing into a fixed max-width. */
|
||||
.channel-control-section {
|
||||
container-type: size;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -50,7 +78,7 @@
|
||||
height: var(--dsp-control-heigth);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
@@ -60,108 +88,27 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.input-control-panel {
|
||||
.channel-control-panel {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 60cqw;
|
||||
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center; /* ← add this */
|
||||
gap: var(--space-4);
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: 2cqh;
|
||||
}
|
||||
|
||||
.input-control-panel__sliders {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4cqh;
|
||||
}
|
||||
|
||||
.input-control-panel__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: 1cqh;
|
||||
}
|
||||
|
||||
.input-control-panel__cell-label {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.5cqh;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-control-panel__side {
|
||||
flex: 0 0 auto;
|
||||
width: 16cqh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 2cqh;
|
||||
}
|
||||
|
||||
.input-control-panel__select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5cqh;
|
||||
}
|
||||
|
||||
.input-control-panel__select-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.2cqh;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-control-panel__select select {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.5cqh;
|
||||
padding: 0.8cqh;
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.input-control-panel__select select:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.input-control-panel__bypass-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.5cqh;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: 1cqh;
|
||||
|
||||
background: var(--bg-void);
|
||||
color: var(--text-primary);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-control-panel__bypass-btn--active {
|
||||
background: var(--accent, #f87171);
|
||||
color: var(--bg-void);
|
||||
border-color: var(--accent, #f87171);
|
||||
}
|
||||
|
||||
/* Peq Sliders */
|
||||
.input-panel {
|
||||
.channel-panel {
|
||||
position: relative;
|
||||
height: 10;
|
||||
height: 100%;
|
||||
|
||||
aspect-ratio: 0.8;
|
||||
flex: 0 0 auto;
|
||||
@@ -177,7 +124,7 @@
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.input-panel__title {
|
||||
.channel-panel__title {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
@@ -193,7 +140,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.input-panel__grid {
|
||||
.channel-panel__grid {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -207,7 +154,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-panel__cell {
|
||||
.channel-panel__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -217,7 +164,7 @@
|
||||
gap: 1cqh;
|
||||
}
|
||||
|
||||
.input-panel__cell-label {
|
||||
.channel-panel__cell-label {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 3.2cqh;
|
||||
@@ -260,15 +207,34 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Now the root of a ChoiceSelect rather than a native <select>. */
|
||||
.crossover-panel__select {
|
||||
flex: 0 0 auto;
|
||||
width: 85%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 3cqh;
|
||||
padding: 1cqh;
|
||||
background: var(--bg-panel);
|
||||
color: inherit;
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
}
|
||||
|
||||
.crossover-panel__select .choice-select__input,
|
||||
.crossover-panel__select .choice-select__input option {
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
/* padding-left: 1cqh; */
|
||||
}
|
||||
|
||||
.crossover-panel__select {
|
||||
flex: 0 0 auto;
|
||||
width: 85%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
}
|
||||
|
||||
.crossover-panel__select .choice-select__input,
|
||||
.crossover-panel__select .choice-select__input option {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
padding-left: 2cqh;
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -288,17 +254,16 @@
|
||||
padding: 1cqh;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
aspect-ratio: 2;
|
||||
}
|
||||
|
||||
.peq-band-list__row {
|
||||
flex: 1 1 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.4fr 1.2fr;
|
||||
grid-template-columns: 0.4fr 0.55fr 0.4fr 0.5fr 1.2fr 0.5fr;
|
||||
align-items: stretch;
|
||||
gap: 1cqh;
|
||||
|
||||
@@ -340,8 +305,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.peq-band-list__input,
|
||||
.peq-band-list__select {
|
||||
.peq-band-list__input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
@@ -362,14 +326,25 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-graph-panel__magnitude-wrap {
|
||||
.peq-band-list__stepper,
|
||||
.peq-band-list__select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 4cqh;
|
||||
}
|
||||
|
||||
.channel-graph-panel__magnitude-wrap {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-graph__title-row {
|
||||
.channel-graph__title-row {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -377,11 +352,11 @@
|
||||
padding: 1cqh 1.5cqh;
|
||||
}
|
||||
|
||||
.input-graph__title-row .input-graph__title {
|
||||
.channel-graph__title-row .channel-graph__title {
|
||||
padding: 0; /* was on the standalone title element; row now owns spacing */
|
||||
}
|
||||
|
||||
.input-graph-view-toggle {
|
||||
.channel-graph-view-toggle {
|
||||
display: flex;
|
||||
gap: 0.4cqh;
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
@@ -390,7 +365,7 @@
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.input-graph-view-toggle__btn {
|
||||
.channel-graph-view-toggle__btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2cqh;
|
||||
padding: 0.6cqh 1.2cqh;
|
||||
@@ -401,7 +376,24 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-graph-view-toggle__btn.active {
|
||||
background: var(--accent, #4dd0e1);
|
||||
.channel-graph-view-toggle__btn.active {
|
||||
background: var(--accent-brand);
|
||||
color: var(--bg-void);
|
||||
}
|
||||
|
||||
.peq-band-list__stepper .choice-stepper__input,
|
||||
.peq-band-list__select .choice-select__input {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 4cqh;
|
||||
color: var(--accent-brand);
|
||||
|
||||
background: transparent;
|
||||
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
// InputSection.tsx
|
||||
// ChannelSection.tsx
|
||||
import VerticalSlider, {
|
||||
makeLogStep,
|
||||
makeLogQuantize,
|
||||
} from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||
import {
|
||||
CROSSOVER_FILTER_LABEL,
|
||||
PEQ_BANDS_ORDER,
|
||||
PEQ_FILTERS_LABEL,
|
||||
PEQ_Q_RANGES,
|
||||
PEAK_Q_VALUES,
|
||||
PEQ_Q_UNUSED,
|
||||
PEQ_GAIN_USED,
|
||||
} from '../../constants';
|
||||
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
|
||||
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
|
||||
import { DSP408 as DSP } from '../../../../types';
|
||||
import { CrossoverFilter } from '../../types';
|
||||
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
|
||||
import { useRef, useState } from 'react';
|
||||
import './InputSection.css';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import './ChannelSection.css';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
// import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
||||
import {
|
||||
FREQUENCY_MAX,
|
||||
FREQUENCY_MIN,
|
||||
@@ -33,6 +36,12 @@ import { getPeqCurvePoints, getPeqPhasePoints } from '../../utils/graph/peq';
|
||||
import { logSpace, sumCurvePoints, sumPhaseCurvePoints } from '../../utils/graph/curve';
|
||||
import { getCrossoverCurvePoints, getCrossoverPhasePoints } from '../../utils/graph/crossover';
|
||||
|
||||
function nearestQValue(q: number, values: readonly number[] = PEAK_Q_VALUES): number {
|
||||
return values.reduce((nearest, current) =>
|
||||
Math.abs(current - q) < Math.abs(nearest - q) ? current : nearest
|
||||
);
|
||||
}
|
||||
|
||||
type GraphView = 'both' | 'magnitude' | 'phase';
|
||||
|
||||
function GraphViewToggle({
|
||||
@@ -49,12 +58,12 @@ function GraphViewToggle({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="input-graph-view-toggle">
|
||||
<div className="channel-graph-view-toggle">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`input-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
|
||||
className={`channel-graph-view-toggle__btn${view === opt.value ? ' active' : ''}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -64,7 +73,7 @@ function GraphViewToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function InputActions({
|
||||
function ChannelActions({
|
||||
bypass,
|
||||
resetting,
|
||||
onBypass,
|
||||
@@ -127,18 +136,22 @@ function PeqPanelSliders({
|
||||
onLiveValue: (value: PEQ) => void;
|
||||
}) {
|
||||
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
|
||||
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
|
||||
|
||||
const nearestQ = nearestQValue(peq.q);
|
||||
|
||||
const commit = (overrides: Partial<PEQ>) => {
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||
return setPEQ(next.gain, next.frequency, overrides.q ?? nearestQ, next.filter_type, next.bypass);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="input-panel">
|
||||
<div className="input-panel__title">PEQ Parameters</div>
|
||||
<div className="channel-panel">
|
||||
<div className="channel-panel__title">PEQ Parameters</div>
|
||||
|
||||
<div className="input-panel__grid">
|
||||
<div className="input-panel__cell input-panel__cell--frequency">
|
||||
<div className="channel-panel__grid">
|
||||
<div className="channel-panel__cell channel-panel__cell--frequency">
|
||||
<VerticalSlider
|
||||
min={FREQUENCY_MIN}
|
||||
max={FREQUENCY_MAX}
|
||||
@@ -155,25 +168,26 @@ function PeqPanelSliders({
|
||||
style={{ width: '100%', flex: '1 1 auto' }}
|
||||
/>
|
||||
|
||||
<div className="input-panel__cell-label">Frequency</div>
|
||||
<div className="channel-panel__cell-label">Frequency</div>
|
||||
</div>
|
||||
|
||||
<div className="input-panel__cell input-panel__cell--q">
|
||||
<div className="channel-panel__cell channel-panel__cell--q">
|
||||
<VerticalSlider
|
||||
min={qMin}
|
||||
max={qMax}
|
||||
values={PEAK_Q_VALUES}
|
||||
formatValue={(v) => v.toFixed(2)}
|
||||
value={peq.q}
|
||||
value={nearestQ}
|
||||
onChange={(value) => commit({ q: value })}
|
||||
onInput={(value) => onLiveValue({ ...peq, q: value })}
|
||||
onInput={(value) => onLiveValue({ ...peq, q: nearestQValue(value) })}
|
||||
disabled={qDisabled}
|
||||
style={{ width: '100%', flex: '1 1 auto' }}
|
||||
/>
|
||||
|
||||
<div className="input-panel__cell-label">Q</div>
|
||||
<div className="channel-panel__cell-label">Q</div>
|
||||
</div>
|
||||
|
||||
<div className="input-panel__cell input-panel__cell--gain">
|
||||
<div className="channel-panel__cell channel-panel__cell--gain">
|
||||
<VerticalSlider
|
||||
min={-12}
|
||||
max={12}
|
||||
@@ -183,10 +197,11 @@ function PeqPanelSliders({
|
||||
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
|
||||
onChange={(value) => commit({ gain: value })}
|
||||
onInput={(value) => onLiveValue({ ...peq, gain: value })}
|
||||
disabled={gainDisabled}
|
||||
style={{ width: '100%', flex: '1 1 auto' }}
|
||||
/>
|
||||
|
||||
<div className="input-panel__cell-label">Gain</div>
|
||||
<div className="channel-panel__cell-label">Gain</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,17 +273,15 @@ function CrossoverPanel({
|
||||
style={{ width: '100%', flex: '1 1 auto' }}
|
||||
/>
|
||||
|
||||
<select
|
||||
<ChoiceSelect
|
||||
className="crossover-panel__select"
|
||||
value={bypass ? lastFilterType.current : filter_type}
|
||||
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
|
||||
>
|
||||
{CROSSOVER_FILTER_LABEL.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={CROSSOVER_FILTER_LABEL.map((opt) => opt.value)}
|
||||
formatLabel={(v) =>
|
||||
CROSSOVER_FILTER_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
|
||||
}
|
||||
onChange={(value) => commit({ filter_type: value })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="toggle"
|
||||
@@ -296,6 +309,7 @@ function PeqBandList({
|
||||
activeBand,
|
||||
setActiveBand,
|
||||
setPEQ,
|
||||
bandsOrder,
|
||||
}: {
|
||||
bands: Record<PEQBand, PEQ>;
|
||||
liveBands: Partial<Record<PEQBand, PEQ>>;
|
||||
@@ -309,11 +323,19 @@ function PeqBandList({
|
||||
filterType: PEQFilter,
|
||||
bypass: boolean
|
||||
) => Promise<boolean>;
|
||||
bandsOrder: PEQBand[];
|
||||
}) {
|
||||
const commit = (band: PEQBand, overrides: Partial<PEQ>) => {
|
||||
const peq = liveBands[band] ?? bands[band];
|
||||
const next = { ...peq, ...overrides };
|
||||
return setPEQ(band, next.gain, next.frequency, next.q, next.filter_type, next.bypass);
|
||||
const live = liveBands[band] ?? bands[band];
|
||||
const next = { ...live, ...overrides };
|
||||
return setPEQ(
|
||||
band,
|
||||
next.gain,
|
||||
next.frequency,
|
||||
nearestQValue(next.q),
|
||||
next.filter_type,
|
||||
next.bypass
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -327,10 +349,17 @@ function PeqBandList({
|
||||
<div className="peq-band-list__cell">Bypass</div>
|
||||
</div>
|
||||
|
||||
{PEQ_BANDS_ORDER.map((band, i) => {
|
||||
{bandsOrder.map((band, i) => {
|
||||
const peq = liveBands[band] ?? bands[band];
|
||||
const isActive = band === activeBand;
|
||||
|
||||
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
|
||||
const qValue = nearestQValue(peq.q, qValues.length ? qValues : PEAK_Q_VALUES);
|
||||
|
||||
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
|
||||
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
||||
@@ -338,68 +367,71 @@ function PeqBandList({
|
||||
>
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={isActive ? 'active' : ''}
|
||||
className={isActive ? 'active-info' : ''}
|
||||
onClick={() => setActiveBand(band)}
|
||||
style={{ minHeight: 0 }}
|
||||
style={{
|
||||
minHeight: 0,
|
||||
fontSize: '4cqh',
|
||||
}}
|
||||
>
|
||||
{`B${i + 1}`}
|
||||
</Button>
|
||||
|
||||
{/* Frequency */}
|
||||
<input
|
||||
className="peq-band-list__input"
|
||||
type="text"
|
||||
value={
|
||||
peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
|
||||
<ChoiceStepper
|
||||
className="peq-band-list__stepper"
|
||||
value={peq.frequency}
|
||||
min={FREQUENCY_MIN}
|
||||
max={FREQUENCY_MAX}
|
||||
step={FREQUENCY_QUANTIZATION}
|
||||
formatLabel={(v) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(2)} kHz` : `${v.toFixed(1)} Hz`
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
commit(band, {
|
||||
frequency: peq.frequency >= 1000 ? value * 1000 : value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={(value) => commit(band, { frequency: value })}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="peq-band-list__input"
|
||||
type="number"
|
||||
step={1}
|
||||
min={1}
|
||||
max={999}
|
||||
value={peq.q}
|
||||
onChange={(e) => commit(band, { q: Number(e.target.value) })}
|
||||
<ChoiceStepper
|
||||
className="peq-band-list__stepper"
|
||||
value={qValue}
|
||||
values={qValues}
|
||||
formatLabel={(v) => v.toFixed(2)}
|
||||
disabled={qDisabled}
|
||||
onChange={(value) => commit(band, { q: value })}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="peq-band-list__input"
|
||||
type="number"
|
||||
step={0.1}
|
||||
<ChoiceStepper
|
||||
className="peq-band-list__stepper"
|
||||
value={peq.gain}
|
||||
min={-12}
|
||||
max={12}
|
||||
value={peq.gain}
|
||||
onChange={(e) => commit(band, { gain: Number(e.target.value) })}
|
||||
step={0.1}
|
||||
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||
disabled={gainDisabled}
|
||||
onChange={(value) => commit(band, { gain: value })}
|
||||
/>
|
||||
|
||||
<select
|
||||
<ChoiceSelect<PEQFilter>
|
||||
className="peq-band-list__select"
|
||||
value={peq.filter_type}
|
||||
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter, q: 3.0 })}
|
||||
>
|
||||
{PEQ_FILTERS_LABEL.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={PEQ_FILTERS_LABEL.map((opt) => opt.value)}
|
||||
formatLabel={(v) =>
|
||||
PEQ_FILTERS_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
|
||||
}
|
||||
onChange={(value) => {
|
||||
const [min, max] = PEQ_Q_RANGES[value];
|
||||
const allowed = PEAK_Q_VALUES.filter((q) => q >= min && q <= max);
|
||||
commit(band, {
|
||||
filter_type: value,
|
||||
q: nearestQValue(3.0, allowed.length ? allowed : PEAK_Q_VALUES),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={peq.bypass ? 'active-warning' : ''}
|
||||
onClick={() => commit(band, { bypass: !peq.bypass })}
|
||||
style={{ minHeight: 0 }}
|
||||
style={{ minHeight: 0, fontSize: '4cqh', paddingInline: 0 }}
|
||||
>
|
||||
{peq.bypass ? 'Bypassed' : 'Bypass'}
|
||||
</Button>
|
||||
@@ -416,76 +448,7 @@ type SetFilterFn = (
|
||||
filterType: CrossoverFilter
|
||||
) => Promise<boolean>;
|
||||
|
||||
/** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
|
||||
// function GraphMarkers({
|
||||
// graphRef,
|
||||
// bands,
|
||||
// activeBand,
|
||||
// setActiveBand,
|
||||
// hpFreq,
|
||||
// hpBypassed,
|
||||
// lpFreq,
|
||||
// lpBypassed,
|
||||
// }: {
|
||||
// graphRef: React.RefObject<LinearGraphHandle>;
|
||||
// bands: Record<PEQBand, PEQ>;
|
||||
// activeBand: PEQBand;
|
||||
// setActiveBand: (band: PEQBand) => void;
|
||||
// hpFreq: number;
|
||||
// hpBypassed: boolean;
|
||||
// lpFreq: number;
|
||||
// lpBypassed: boolean;
|
||||
// }) {
|
||||
// const g = graphRef.current;
|
||||
// if (!g) return null;
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// {PEQ_BANDS_ORDER.map((band, i) => {
|
||||
// const peq = bands[band];
|
||||
// const left = g.toPxX(peq.frequency);
|
||||
// const top = g.toPxY(peq.gain);
|
||||
// const isActive = band === activeBand;
|
||||
|
||||
// return (
|
||||
// <button
|
||||
// key={band}
|
||||
// type="button"
|
||||
// className={`graph-marker graph-marker--peq${isActive ? ' graph-marker--active' : ''}${
|
||||
// peq.bypass ? ' graph-marker--bypassed' : ''
|
||||
// }`}
|
||||
// style={{ left, top }}
|
||||
// onClick={() => setActiveBand(band)}
|
||||
// title={`B${i + 1} · ${Math.round(peq.frequency)} Hz · ${peq.gain.toFixed(1)} dB`}
|
||||
// >
|
||||
// {`B${i + 1}`}
|
||||
// </button>
|
||||
// );
|
||||
// })}
|
||||
|
||||
// <div
|
||||
// className={`graph-marker graph-marker--crossover graph-marker--hp${
|
||||
// hpBypassed ? ' graph-marker--bypassed' : ''
|
||||
// }`}
|
||||
// style={{ left: g.toPxX(hpFreq), top: g.toPxY(0) }}
|
||||
// title={`HPF · ${Math.round(hpFreq)} Hz`}
|
||||
// >
|
||||
// HP
|
||||
// </div>
|
||||
// <div
|
||||
// className={`graph-marker graph-marker--crossover graph-marker--lp${
|
||||
// lpBypassed ? ' graph-marker--bypassed' : ''
|
||||
// }`}
|
||||
// style={{ left: g.toPxX(lpFreq), top: g.toPxY(0) }}
|
||||
// title={`LPF · ${Math.round(lpFreq)} Hz`}
|
||||
// >
|
||||
// LP
|
||||
// </div>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
||||
function InputSection({
|
||||
function ChannelSection({
|
||||
dsp,
|
||||
channel,
|
||||
setHighPass,
|
||||
@@ -494,7 +457,7 @@ function InputSection({
|
||||
setPeqBypass,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
channel: InputChannel;
|
||||
channel: Channel;
|
||||
setHighPass: SetFilterFn;
|
||||
setLowPass: SetFilterFn;
|
||||
setPeq: (
|
||||
@@ -512,6 +475,7 @@ function InputSection({
|
||||
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
||||
const [graphView, setGraphView] = useState<GraphView>('both');
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||
|
||||
// Live values
|
||||
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
||||
@@ -521,27 +485,56 @@ function InputSection({
|
||||
const magnitudeGraphRef = useRef<LinearGraphHandle>(null);
|
||||
|
||||
// DSP values
|
||||
const activeInputState = dsp.state.current_config.input_states[channel];
|
||||
const ActiveHighPassFilter = activeInputState.crossover.high_pass;
|
||||
const ActiveLowPassFilter = activeInputState.crossover.low_pass;
|
||||
const ActivePeq = activeInputState.peq_chain;
|
||||
const isInput = 'Input' in channel;
|
||||
|
||||
const activeChannelState = isInput
|
||||
? dsp.state.current_config.input_states[channel.Input]
|
||||
: dsp.state.current_config.output_states[channel.Output];
|
||||
const ActiveHighPassFilter = activeChannelState.crossover.high_pass;
|
||||
const ActiveLowPassFilter = activeChannelState.crossover.low_pass;
|
||||
const ActivePeq = activeChannelState.peq_chain;
|
||||
|
||||
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResetConfirmOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsResetConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isResetConfirmOpen]);
|
||||
|
||||
const handleResetClick = () => {
|
||||
setIsResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleResetPeq = async () => {
|
||||
if (resetting) return;
|
||||
setResetting(true);
|
||||
|
||||
for (const band of PEQ_BANDS_ORDER) {
|
||||
for (const band of bandsOrder) {
|
||||
const current = displayPeq[band];
|
||||
if (current.gain === 0 || current.filter_type === PEQFilter.Peak) continue; // skip bands already flat
|
||||
// Skip only if already flat — nothing to change.
|
||||
if (current.gain === 0 && current.filter_type === PEQFilter.Peak) continue;
|
||||
|
||||
await commitPEQ(band, 0, current.frequency, current.q, PEQFilter.Peak, current.bypass);
|
||||
await commitPEQ(band, 0, current.frequency, nearestQValue(3.0, PEAK_Q_VALUES), PEQFilter.Peak, current.bypass);
|
||||
}
|
||||
|
||||
setResetting(false);
|
||||
};
|
||||
|
||||
const confirmResetPeq = async () => {
|
||||
setIsResetConfirmOpen(false);
|
||||
await handleResetPeq();
|
||||
};
|
||||
|
||||
const handleBypassClick = () => {
|
||||
setPeqBypass({ Input: channel }, !ActivePeq.bypass);
|
||||
setPeqBypass(channel, !ActivePeq.bypass);
|
||||
};
|
||||
|
||||
// Live handlers
|
||||
@@ -562,7 +555,7 @@ function InputSection({
|
||||
|
||||
// Real dsp value handlers
|
||||
const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
|
||||
const success = await setHighPass({ Input: channel }, freq, filterType);
|
||||
const success = await setHighPass(channel, freq, filterType);
|
||||
if (success) {
|
||||
setLiveHpFreq(freq);
|
||||
}
|
||||
@@ -570,7 +563,7 @@ function InputSection({
|
||||
};
|
||||
|
||||
const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
|
||||
const success = await setLowPass({ Input: channel }, freq, filterType);
|
||||
const success = await setLowPass(channel, freq, filterType);
|
||||
if (success) {
|
||||
setLiveLpFreq(freq);
|
||||
}
|
||||
@@ -585,7 +578,7 @@ function InputSection({
|
||||
filter_type: PEQFilter,
|
||||
bypass: boolean
|
||||
) => {
|
||||
const success = await setPeq({ Input: channel }, band, gain, frequency, q, filter_type, bypass);
|
||||
const success = await setPeq(channel, band, gain, frequency, q, filter_type, bypass);
|
||||
if (success) {
|
||||
setLivePeq((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -609,15 +602,15 @@ function InputSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="input-graph-section">
|
||||
<div className="input-graph-panel">
|
||||
<div className="input-graph__title-row">
|
||||
<div className="input-graph__title">{activeInputState.name} - Filters</div>
|
||||
<div className="channel-graph-section">
|
||||
<div className="channel-graph-panel">
|
||||
<div className="channel-graph__title-row">
|
||||
<div className="channel-graph__title">{activeChannelState.name} - Filters</div>
|
||||
<GraphViewToggle view={graphView} onChange={setGraphView} />
|
||||
</div>
|
||||
|
||||
{(graphView === 'both' || graphView === 'magnitude') && (
|
||||
<div className="input-graph-panel__magnitude-wrap">
|
||||
<div className="channel-graph-panel__magnitude-wrap">
|
||||
<LinearGraph
|
||||
ref={magnitudeGraphRef}
|
||||
xMin={GRAPH_X_MIN}
|
||||
@@ -626,8 +619,8 @@ function InputSection({
|
||||
yMin={GRAPH_Y_MIN}
|
||||
yMax={GRAPH_Y_MAX}
|
||||
yStep={3}
|
||||
xLabel="Hz"
|
||||
yLabel="dB"
|
||||
xLabel="Frequency - Hz"
|
||||
yLabel="Magnituded - dB"
|
||||
showGrid={true}
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => Math.round(v).toString()}
|
||||
@@ -635,7 +628,6 @@ function InputSection({
|
||||
drawCurves({
|
||||
ctx,
|
||||
toPx,
|
||||
|
||||
xMin: GRAPH_X_MIN,
|
||||
xMax: GRAPH_X_MAX,
|
||||
yMin: GRAPH_Y_MIN,
|
||||
@@ -667,8 +659,8 @@ function InputSection({
|
||||
yMin={-180}
|
||||
yMax={180}
|
||||
yStep={45}
|
||||
xLabel="Hz"
|
||||
yLabel="°"
|
||||
xLabel="Frequency - Hz"
|
||||
yLabel="Phase - Deg"
|
||||
showGrid={true}
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => Math.round(v).toString()}
|
||||
@@ -676,7 +668,6 @@ function InputSection({
|
||||
drawCurves({
|
||||
ctx,
|
||||
toPx,
|
||||
|
||||
xMin: GRAPH_X_MIN,
|
||||
xMax: GRAPH_X_MAX,
|
||||
yMin: GRAPH_Y_MIN,
|
||||
@@ -698,11 +689,17 @@ function InputSection({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ActivePeq.bypass && (
|
||||
<div className="channel-bypass-overlay">
|
||||
<span>BYPASSED</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-control-section">
|
||||
<div className="input-control-panel">
|
||||
<div className="channel-control-section">
|
||||
<div className="channel-control-panel">
|
||||
<ChannelGroup title="PEQ">
|
||||
<PeqBandList
|
||||
bands={ActivePeq.bands}
|
||||
@@ -710,6 +707,7 @@ function InputSection({
|
||||
activeBand={activeBand}
|
||||
setActiveBand={setActiveBand}
|
||||
setPEQ={commitPEQ}
|
||||
bandsOrder={bandsOrder}
|
||||
/>
|
||||
<PeqPanelSliders
|
||||
peq={ActivePeq.bands[activeBand]}
|
||||
@@ -736,17 +734,38 @@ function InputSection({
|
||||
</ChannelGroup>
|
||||
|
||||
<ChannelGroup title="Actions">
|
||||
<InputActions
|
||||
<ChannelActions
|
||||
bypass={ActivePeq.bypass}
|
||||
resetting={resetting}
|
||||
onBypass={handleBypassClick}
|
||||
onReset={handleResetPeq}
|
||||
onReset={handleResetClick}
|
||||
/>
|
||||
</ChannelGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isResetConfirmOpen && (
|
||||
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Reset {activeChannelState.name} PEQ?</h3>
|
||||
<p>
|
||||
This will set all PEQ bands on {activeChannelState.name} back to flat (0 dB, Peak).
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="confirm-modal__actions">
|
||||
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmResetPeq}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InputSection;
|
||||
export default ChannelSection;
|
||||
@@ -18,17 +18,23 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.compressor-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compressor-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
aspect-ratio: 0.5;
|
||||
flex: 0 0 auto;
|
||||
@@ -91,8 +97,9 @@
|
||||
|
||||
.compressor-panel__cell--steppers {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: stretch;
|
||||
gap: 1cqh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.compressor-panel__stepper-item {
|
||||
@@ -108,6 +115,19 @@
|
||||
gap: 1cqh;
|
||||
}
|
||||
|
||||
.compressor-panel__stepper-item .choice-stepper {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.compressor-panel__stepper {
|
||||
display: flex;
|
||||
font-size: 4cqh;
|
||||
text-align: center;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compressor-panel__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -142,6 +162,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.compressor-graph-panel {
|
||||
@@ -206,3 +229,15 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compressor-panel__stepper--centered .choice-stepper__input {
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compressor-panel__stepper--centered .choice-stepper__arrows {
|
||||
flex: 0 0 20%;
|
||||
width: 20%;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
@@ -8,67 +8,7 @@ import MeterBar from '../../../../components/MeterBar/MeterBar';
|
||||
import { useMemo, useState } from 'react';
|
||||
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||
import './CompressorSection.css';
|
||||
|
||||
// Enum order defines stepper order (1:1 -> Limit).
|
||||
const RATIO_ORDER: Ratio[] = [
|
||||
Ratio.Ratio1_1,
|
||||
Ratio.Ratio1_1_1,
|
||||
Ratio.Ratio1_1_3,
|
||||
Ratio.Ratio1_1_5,
|
||||
Ratio.Ratio1_1_7,
|
||||
Ratio.Ratio1_2,
|
||||
Ratio.Ratio1_2_5,
|
||||
Ratio.Ratio1_3,
|
||||
Ratio.Ratio1_3_5,
|
||||
Ratio.Ratio1_4,
|
||||
Ratio.Ratio1_5,
|
||||
Ratio.Ratio1_6,
|
||||
Ratio.Ratio1_8,
|
||||
Ratio.Ratio1_10,
|
||||
Ratio.Ratio1_20,
|
||||
Ratio.Limit,
|
||||
];
|
||||
|
||||
const RATIO_LABEL: Record<Ratio, string> = {
|
||||
[Ratio.Ratio1_1]: '1:1',
|
||||
[Ratio.Ratio1_1_1]: '1.1:1',
|
||||
[Ratio.Ratio1_1_3]: '1.3:1',
|
||||
[Ratio.Ratio1_1_5]: '1.5:1',
|
||||
[Ratio.Ratio1_1_7]: '1.7:1',
|
||||
[Ratio.Ratio1_2]: '2:1',
|
||||
[Ratio.Ratio1_2_5]: '2.5:1',
|
||||
[Ratio.Ratio1_3]: '3:1',
|
||||
[Ratio.Ratio1_3_5]: '3.5:1',
|
||||
[Ratio.Ratio1_4]: '4:1',
|
||||
[Ratio.Ratio1_5]: '5:1',
|
||||
[Ratio.Ratio1_6]: '6:1',
|
||||
[Ratio.Ratio1_8]: '8:1',
|
||||
[Ratio.Ratio1_10]: '10:1',
|
||||
[Ratio.Ratio1_20]: '20:1',
|
||||
[Ratio.Limit]: 'Limit',
|
||||
};
|
||||
|
||||
// Numeric ratio used only for drawing the transfer curve; Limit ~= brickwall.
|
||||
const RATIO_NUMERIC: Record<Ratio, number> = {
|
||||
[Ratio.Ratio1_1]: 1,
|
||||
[Ratio.Ratio1_1_1]: 1.1,
|
||||
[Ratio.Ratio1_1_3]: 1.3,
|
||||
[Ratio.Ratio1_1_5]: 1.5,
|
||||
[Ratio.Ratio1_1_7]: 1.7,
|
||||
[Ratio.Ratio1_2]: 2,
|
||||
[Ratio.Ratio1_2_5]: 2.5,
|
||||
[Ratio.Ratio1_3]: 3,
|
||||
[Ratio.Ratio1_3_5]: 3.5,
|
||||
[Ratio.Ratio1_4]: 4,
|
||||
[Ratio.Ratio1_5]: 5,
|
||||
[Ratio.Ratio1_6]: 6,
|
||||
[Ratio.Ratio1_8]: 8,
|
||||
[Ratio.Ratio1_10]: 10,
|
||||
[Ratio.Ratio1_20]: 20,
|
||||
[Ratio.Limit]: 1000,
|
||||
};
|
||||
|
||||
const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
|
||||
import { RATIO_ORDER, RATIO_LABEL, RATIO_NUMERIC, KNEE_VALUES } from '../../constants';
|
||||
|
||||
const COMPRESSOR_SLIDER_PARAMS = [
|
||||
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
|
||||
@@ -76,19 +16,6 @@ const COMPRESSOR_SLIDER_PARAMS = [
|
||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||
] as const;
|
||||
|
||||
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
|
||||
// Out1 -> 4, Out2 -> 5, etc. Adjust to match dsp.meters.channels ordering.
|
||||
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
|
||||
[OutputChannel.Out1]: 4,
|
||||
[OutputChannel.Out2]: 5,
|
||||
[OutputChannel.Out3]: 6,
|
||||
[OutputChannel.Out4]: 7,
|
||||
[OutputChannel.Out5]: 8,
|
||||
[OutputChannel.Out6]: 9,
|
||||
[OutputChannel.Out7]: 10,
|
||||
[OutputChannel.Out8]: 11,
|
||||
} as const;
|
||||
|
||||
type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
|
||||
|
||||
type CompressorValues = {
|
||||
@@ -154,6 +81,7 @@ function CompressorPanel({
|
||||
<div className="compressor-panel__cell compressor-panel__cell--steppers">
|
||||
<div className="compressor-panel__stepper-item">
|
||||
<ChoiceStepper
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.ratio}
|
||||
values={RATIO_ORDER}
|
||||
formatLabel={(v) => RATIO_LABEL[v]}
|
||||
@@ -167,6 +95,7 @@ function CompressorPanel({
|
||||
|
||||
<div className="compressor-panel__stepper-item">
|
||||
<ChoiceStepper
|
||||
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||
value={comp.knee}
|
||||
values={KNEE_VALUES}
|
||||
formatLabel={(v) => `${v} dB`}
|
||||
@@ -284,7 +213,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
||||
const steps = 120;
|
||||
const points: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = -100 + (100 * i) / steps;
|
||||
const x = -100 + (120 * i) / steps;
|
||||
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
|
||||
}
|
||||
return points;
|
||||
@@ -298,9 +227,9 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
||||
|
||||
<LinearGraph
|
||||
xMin={-100}
|
||||
xMax={0}
|
||||
xMax={20}
|
||||
yMin={-100}
|
||||
yMax={0}
|
||||
yMax={20}
|
||||
xLabel="in dB"
|
||||
yLabel="out dB"
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
@@ -311,7 +240,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
const [ux0, uy0] = toPx(-100, -100);
|
||||
const [ux1, uy1] = toPx(0, 0);
|
||||
const [ux1, uy1] = toPx(20, 20);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ux0, uy0);
|
||||
ctx.lineTo(ux1, uy1);
|
||||
@@ -323,7 +252,7 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 3]);
|
||||
const [tx0, ty0] = toPx(activeComp.threshold, -100);
|
||||
const [tx1, ty1] = toPx(activeComp.threshold, 0);
|
||||
const [tx1, ty1] = toPx(activeComp.threshold, 20);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx0, ty0);
|
||||
ctx.lineTo(tx1, ty1);
|
||||
@@ -349,10 +278,13 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
||||
<div className="compressor-graph__meters">
|
||||
{outputs.map((output) => {
|
||||
const outputState = dsp.state.current_config.output_states[output];
|
||||
const idx = OUTPUT_METER_INDEX[output];
|
||||
|
||||
return (
|
||||
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
|
||||
<MeterBar
|
||||
key={output}
|
||||
label={outputState.name}
|
||||
meter={dsp.output_meters.meters[output]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DSP } from '../../../../types';
|
||||
import { DSP, isConnected } from '../../../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
import Led from '../../../../components/Led/Led';
|
||||
@@ -14,13 +14,14 @@ function ConnectionPanel({
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const statusLabel =
|
||||
dsp.status === 'connected'
|
||||
? 'Connected'
|
||||
: dsp.status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: 'Disconnected';
|
||||
|
||||
const statusLabel =
|
||||
isConnected(dsp)
|
||||
? 'Connected'
|
||||
: dsp.isConnecting
|
||||
? 'Connecting…'
|
||||
: 'Disconnected';
|
||||
|
||||
return (
|
||||
<div className="connection-section">
|
||||
<Card className="connection-panel">
|
||||
@@ -35,10 +36,10 @@ function ConnectionPanel({
|
||||
|
||||
<div className="connection-panel__status">
|
||||
<span className="connection-panel__led">
|
||||
<Led status={dsp.status} />
|
||||
<Led status={dsp.isConnecting ? "connecting" : isConnected(dsp) ? "connected" : "disconnected"} />
|
||||
</span>
|
||||
|
||||
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
|
||||
<span>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,13 +65,13 @@ function ConnectionPanel({
|
||||
</dl>
|
||||
|
||||
<div className="connection-panel__actions">
|
||||
{dsp.status === 'connected' ? (
|
||||
{isConnected(dsp) ? (
|
||||
<Button variant="outline" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
|
||||
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
|
||||
<Button variant="primary" onClick={onConnect} disabled={dsp.isConnecting}>
|
||||
{dsp.isConnecting ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,3 +29,9 @@
|
||||
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
||||
left: var(--sidebar-width-expanded);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState, type SetStateAction } from 'react';
|
||||
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
|
||||
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
||||
import {
|
||||
BoltIcon,
|
||||
CompressorIcon,
|
||||
DelayIcon,
|
||||
GainIcon,
|
||||
GateIcon,
|
||||
InfoIcon,
|
||||
LimiterIcon,
|
||||
MatrixIcon,
|
||||
SlidersVerticalIcon,
|
||||
TextIcon,
|
||||
} from '../../../../assets/icons/AudioIcons';
|
||||
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||
import GainSection from '../GainSection/GainSection';
|
||||
import CompressorSection from '../CompressorSection/CompressorSection';
|
||||
@@ -11,91 +22,78 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
|
||||
import { InputChannel, OutputChannel } from '../../types';
|
||||
import MatrixSection from '../Matrixsection/Matrixsection';
|
||||
import GeqSection from '../GeqSection/GeqSection';
|
||||
import InputSection from '../InputSection/InputSection';
|
||||
import ChannelSection from '../ChannelSection/ChannelSection';
|
||||
import { DSP408Api } from '../../dsp408';
|
||||
import { DSP408 } from '../../../../types';
|
||||
import { SidebarId } from '../../constants';
|
||||
import './DSP408Panel.css';
|
||||
import MiscellaneousPanel from '../MiscellaneousSection/MiscellaneousSection';
|
||||
import { PasswordDialog } from '../PasswordDialog/PasswordDialog';
|
||||
|
||||
const activeSidebarByDsp = new Map<number, SidebarId>();
|
||||
|
||||
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
|
||||
const [selectedPreset, setSelectedPreset] = useState(dsp.state.presets.current_index);
|
||||
const [presetLoading, setPresetLoading] = useState(false);
|
||||
const inputStates = dsp.state?.current_config.input_states;
|
||||
const outputStates = dsp.state?.current_config.output_states;
|
||||
|
||||
const inputStates = dsp.state.current_config.input_states;
|
||||
const outputStates = dsp.state.current_config.output_states;
|
||||
|
||||
const sidebarItems: SidebarItem<SidebarId>[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
||||
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
||||
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
|
||||
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
|
||||
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
|
||||
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
|
||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
|
||||
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
|
||||
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> },
|
||||
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
|
||||
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
|
||||
{
|
||||
id: 'out1',
|
||||
label: `${outputStates[OutputChannel.Out1].name}`,
|
||||
icon: <TextIcon>Out 1</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out2',
|
||||
label: `${outputStates[OutputChannel.Out2].name}`,
|
||||
icon: <TextIcon>Out 2</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out3',
|
||||
label: `${outputStates[OutputChannel.Out3].name}`,
|
||||
icon: <TextIcon>Out 3</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out4',
|
||||
label: `${outputStates[OutputChannel.Out4].name}`,
|
||||
icon: <TextIcon>Out 4</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out5',
|
||||
label: `${outputStates[OutputChannel.Out5].name}`,
|
||||
icon: <TextIcon>Out 5</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out6',
|
||||
label: `${outputStates[OutputChannel.Out6].name}`,
|
||||
icon: <TextIcon>Out 6</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out7',
|
||||
label: `${outputStates[OutputChannel.Out7].name}`,
|
||||
icon: <TextIcon>Out 7</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out8',
|
||||
label: `${outputStates[OutputChannel.Out8].name}`,
|
||||
icon: <TextIcon>Out 8</TextIcon>,
|
||||
},
|
||||
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
|
||||
{ id: 'gain', label: 'Gain', icon: <GainIcon /> },
|
||||
{ id: 'gate', label: 'Gate', icon: <GateIcon /> },
|
||||
{ id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
|
||||
{ id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
|
||||
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
|
||||
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
|
||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
|
||||
{ id: 'ina', label: inputStates[InputChannel.InA].name, icon: <TextIcon>In A</TextIcon>, editable: true },
|
||||
{ id: 'inb', label: inputStates[InputChannel.InB].name, icon: <TextIcon>In B</TextIcon>, editable: true },
|
||||
{ id: 'inc', label: inputStates[InputChannel.InC].name, icon: <TextIcon>In C</TextIcon>, editable: true },
|
||||
{ id: 'ind', label: inputStates[InputChannel.InD].name, icon: <TextIcon>In D</TextIcon>, editable: true },
|
||||
{ id: 'out1', label: outputStates[OutputChannel.Out1].name, icon: <TextIcon>Out 1</TextIcon>, editable: true },
|
||||
{ id: 'out2', label: outputStates[OutputChannel.Out2].name, icon: <TextIcon>Out 2</TextIcon>, editable: true },
|
||||
{ id: 'out3', label: outputStates[OutputChannel.Out3].name, icon: <TextIcon>Out 3</TextIcon>, editable: true },
|
||||
{ id: 'out4', label: outputStates[OutputChannel.Out4].name, icon: <TextIcon>Out 4</TextIcon>, editable: true },
|
||||
{ id: 'out5', label: outputStates[OutputChannel.Out5].name, icon: <TextIcon>Out 5</TextIcon>, editable: true },
|
||||
{ id: 'out6', label: outputStates[OutputChannel.Out6].name, icon: <TextIcon>Out 6</TextIcon>, editable: true },
|
||||
{ id: 'out7', label: outputStates[OutputChannel.Out7].name, icon: <TextIcon>Out 7</TextIcon>, editable: true },
|
||||
{ id: 'out8', label: outputStates[OutputChannel.Out8].name, icon: <TextIcon>Out 8</TextIcon>, editable: true },
|
||||
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
|
||||
];
|
||||
|
||||
const presets = dsp.state?.presets.names
|
||||
? [
|
||||
{ id: 'f00', name: 'Factory Preset' },
|
||||
...dsp.state.presets.names.map((name, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name,
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{ id: 'f00', name: 'Factory Preset' },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name: 'Default Preset',
|
||||
})),
|
||||
];
|
||||
const presets = [
|
||||
{ id: 'f00', name: 'Factory Preset' },
|
||||
...(dsp.state.presets.names.map((name, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name,
|
||||
})) ??
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||
name: 'Default Preset',
|
||||
}))),
|
||||
];
|
||||
|
||||
const [activeSidebar, setActiveSidebarState] = useState<SidebarId>(
|
||||
() => activeSidebarByDsp.get(dsp.id) ?? 'overview'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = activeSidebarByDsp.get(dsp.id) ?? 'overview';
|
||||
setActiveSidebarState(saved);
|
||||
}, [dsp.id]);
|
||||
|
||||
const setActiveSidebar = (value: SetStateAction<SidebarId>) => {
|
||||
setActiveSidebarState(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
|
||||
activeSidebarByDsp.set(dsp.id, next);
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onAppChange = async (id: string) => {
|
||||
const presetIndex = presets.findIndex((preset) => preset.id === id);
|
||||
@@ -107,6 +105,43 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
setPresetLoading(false);
|
||||
};
|
||||
|
||||
const onItemRename = async (id: SidebarId, name: string) => {
|
||||
const channel = channelMap[id];
|
||||
if (!channel) return;
|
||||
|
||||
const padded = name.slice(0, 8).padEnd(8, '\0');
|
||||
await api.setChannelName(channel, padded);
|
||||
};
|
||||
|
||||
const renderChannel = (channel: { Input: InputChannel } | { Output: OutputChannel }) => (
|
||||
<ChannelSection
|
||||
dsp={dsp}
|
||||
channel={channel}
|
||||
setLowPass={api.setLowPass}
|
||||
setHighPass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setPeqBypass={api.setPEQBypass}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelMap: Partial<
|
||||
Record<SidebarId, { Input: InputChannel } | { Output: OutputChannel }>
|
||||
> = {
|
||||
ina: { Input: InputChannel.InA },
|
||||
inb: { Input: InputChannel.InB },
|
||||
inc: { Input: InputChannel.InC },
|
||||
ind: { Input: InputChannel.InD },
|
||||
|
||||
out1: { Output: OutputChannel.Out1 },
|
||||
out2: { Output: OutputChannel.Out2 },
|
||||
out3: { Output: OutputChannel.Out3 },
|
||||
out4: { Output: OutputChannel.Out4 },
|
||||
out5: { Output: OutputChannel.Out5 },
|
||||
out6: { Output: OutputChannel.Out6 },
|
||||
out7: { Output: OutputChannel.Out7 },
|
||||
out8: { Output: OutputChannel.Out8 },
|
||||
};
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeSidebar) {
|
||||
case 'overview':
|
||||
@@ -134,54 +169,6 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
case 'delay':
|
||||
return <DelaySection dsp={dsp} setDelay={api.setDelay} setDelayUnit={api.setDelayUnit} />;
|
||||
|
||||
case 'ina':
|
||||
return (
|
||||
<InputSection
|
||||
dsp={dsp}
|
||||
channel={InputChannel.InA}
|
||||
setLowPass={api.setLowPass}
|
||||
setHighPass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setPeqBypass={api.setPEQBypass}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'inb':
|
||||
return (
|
||||
<InputSection
|
||||
dsp={dsp}
|
||||
channel={InputChannel.InB}
|
||||
setLowPass={api.setLowPass}
|
||||
setHighPass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setPeqBypass={api.setPEQBypass}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'inc':
|
||||
return (
|
||||
<InputSection
|
||||
dsp={dsp}
|
||||
channel={InputChannel.InC}
|
||||
setLowPass={api.setLowPass}
|
||||
setHighPass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setPeqBypass={api.setPEQBypass}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'ind':
|
||||
return (
|
||||
<InputSection
|
||||
dsp={dsp}
|
||||
channel={InputChannel.InD}
|
||||
setLowPass={api.setLowPass}
|
||||
setHighPass={api.setHighPass}
|
||||
setPeq={api.setPeqBand}
|
||||
setPeqBypass={api.setPEQBypass}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'matrix':
|
||||
return (
|
||||
<MatrixSection
|
||||
@@ -195,33 +182,58 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
case 'geq':
|
||||
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
case 'miscellaneous':
|
||||
return (
|
||||
<MiscellaneousPanel
|
||||
dsp={dsp}
|
||||
factoryReset={api.factoryReset}
|
||||
copyChannel={api.copyChannel}
|
||||
setInputSource={api.setInputSource}
|
||||
linkChannels={api.linkChannels}
|
||||
lockDevice={api.lockDevice}
|
||||
setActiveSidebar={setActiveSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
default: {
|
||||
const channel = channelMap[activeSidebar];
|
||||
return channel ? renderChannel(channel) : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
||||
<Sidebar
|
||||
items={sidebarItems}
|
||||
activeId={activeSidebar}
|
||||
onSelect={setActiveSidebar}
|
||||
appName={dsp.name}
|
||||
appOptions={presets}
|
||||
selectedAppId={presets[selectedPreset]?.id}
|
||||
onAppChange={onAppChange}
|
||||
onAppRename={api.setCurrentPresetName}
|
||||
expanded={sidebarExpanded}
|
||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||
/>
|
||||
<Sidebar
|
||||
items={sidebarItems}
|
||||
activeId={activeSidebar}
|
||||
onSelect={setActiveSidebar}
|
||||
appName={dsp.name}
|
||||
appOptions={presets}
|
||||
selectedAppId={presets[selectedPreset]?.id}
|
||||
onAppChange={onAppChange}
|
||||
onAppRename={api.setCurrentPresetName}
|
||||
onItemRename={onItemRename}
|
||||
expanded={sidebarExpanded}
|
||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||
/>
|
||||
|
||||
<LoadingOverlay
|
||||
active={presetLoading}
|
||||
label="Recalling preset…"
|
||||
overlayClassName="dsp-loading-overlay"
|
||||
>
|
||||
<div className="dsp-content">{renderTab()}</div>
|
||||
</LoadingOverlay>
|
||||
<LoadingOverlay
|
||||
active={presetLoading}
|
||||
label="Recalling preset…"
|
||||
overlayClassName="dsp-loading-overlay"
|
||||
>
|
||||
<div className="dsp-content">{renderTab()}</div>
|
||||
</LoadingOverlay>
|
||||
|
||||
|
||||
{dsp.passwordRequired && (
|
||||
<PasswordDialog
|
||||
name={dsp.name}
|
||||
onSubmit={(pw: string) => api.connectWithPassword(pw)}
|
||||
onCancel={() => api.cancelPassword()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,18 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.delay-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.delay-panel {
|
||||
@@ -86,6 +91,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.delay-graph-panel {
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.gain-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -74,6 +77,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.gain-graph-panel {
|
||||
@@ -106,37 +112,96 @@
|
||||
|
||||
.gain-graph-title-row {
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
gap: 0.75cqh;
|
||||
padding: 1cqh 1.5cqh;
|
||||
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* First row: title left, Show stages right */
|
||||
.gain-graph-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 1cqh;
|
||||
padding: 1cqh 1.5cqh;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gain-graph-title-row .gain-graph-title {
|
||||
.gain-graph-title-line .gain-graph-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
padding: 0;
|
||||
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Compact button on the right */
|
||||
.gain-stages-toggle {
|
||||
flex: 0 0 auto;
|
||||
|
||||
width: auto !important;
|
||||
min-width: max-content;
|
||||
|
||||
padding: 0.4cqh 1cqh;
|
||||
font-size: 1.8cqh;
|
||||
line-height: 1.2;
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Channel Selector */
|
||||
.magnitude-selector {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
gap: 0.5cqh;
|
||||
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.magnitude-selector__chip {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
gap: 0.6cqh;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1.8cqh;
|
||||
|
||||
padding: 0.4cqh 1cqh;
|
||||
|
||||
background: var(--bg-void);
|
||||
color: var(--text-muted);
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.magnitude-selector__chip.active {
|
||||
@@ -148,7 +213,17 @@
|
||||
.magnitude-selector__swatch {
|
||||
width: 1.2cqh;
|
||||
height: 1.2cqh;
|
||||
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.magnitude-selector__label {
|
||||
min-width: 0;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
||||
import { InputChannel, Channel, OutputChannel } from '../../types';
|
||||
import { InputChannel, Channel, OutputChannel, CrossoverFilter, PEQBand, PEQ } from '../../types';
|
||||
import { DSP408 as DSP } from '../../../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||
@@ -58,18 +58,34 @@ function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
|
||||
|
||||
function getPeqCrossoverPoints(
|
||||
freqs: number[],
|
||||
peqChain: { bands: Record<string, unknown> },
|
||||
peqChain: {
|
||||
bypass: boolean;
|
||||
bands: Record<PEQBand, PEQ>;
|
||||
},
|
||||
crossover: {
|
||||
high_pass: { frequency: number; slope: unknown };
|
||||
low_pass: { frequency: number; slope: unknown };
|
||||
high_pass: { frequency: number; slope: CrossoverFilter };
|
||||
low_pass: { frequency: number; slope: CrossoverFilter };
|
||||
}
|
||||
) {
|
||||
return sumCurvePoints([
|
||||
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }),
|
||||
...(peqChain.bypass
|
||||
? []
|
||||
: [
|
||||
getPeqCurvePoints({
|
||||
freqs,
|
||||
bands: peqChain.bands,
|
||||
}),
|
||||
]),
|
||||
getCrossoverCurvePoints({
|
||||
freqs,
|
||||
highPass: { frequency: crossover.high_pass.frequency, filter_type: crossover.high_pass.slope as any },
|
||||
lowPass: { frequency: crossover.low_pass.frequency, filter_type: crossover.low_pass.slope as any },
|
||||
highPass: {
|
||||
frequency: crossover.high_pass.frequency,
|
||||
filter_type: crossover.high_pass.slope,
|
||||
},
|
||||
lowPass: {
|
||||
frequency: crossover.low_pass.frequency,
|
||||
filter_type: crossover.low_pass.slope,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -80,9 +96,19 @@ function getInputChannelCurves(
|
||||
liveGain: number | undefined
|
||||
): ChannelCurves {
|
||||
const gain = liveGain ?? state.gain;
|
||||
|
||||
const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0);
|
||||
const geq = getGeqCurvePoints({ freqs, values: geqValues, q: GEQ_CURVE_Q });
|
||||
|
||||
const geq = state.geq.bypass
|
||||
? []
|
||||
: getGeqCurvePoints({
|
||||
freqs,
|
||||
values: geqValues,
|
||||
q: GEQ_CURVE_Q,
|
||||
});
|
||||
|
||||
const peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
|
||||
|
||||
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
|
||||
|
||||
return { geq, peqCrossover, total };
|
||||
@@ -111,7 +137,7 @@ function buildCurvesForChannels<T extends string>(
|
||||
): Curve[] {
|
||||
const curves: Curve[] = [];
|
||||
|
||||
channels.forEach((channel) => {
|
||||
[...channels].reverse().forEach((channel) => {
|
||||
if (!selected.has(channel)) return;
|
||||
const { geq, peqCrossover, total } = getCurves(channel);
|
||||
const color = colors[channel];
|
||||
@@ -286,9 +312,10 @@ function ChannelSelector<T extends string>({
|
||||
className={`magnitude-selector__chip${isOn ? ' active' : ''}`}
|
||||
style={{ '--chip-color': colors[channel] } as React.CSSProperties}
|
||||
onClick={() => onToggle(channel)}
|
||||
title={labels[channel]}
|
||||
>
|
||||
<span className="magnitude-selector__swatch" />
|
||||
{labels[channel]}
|
||||
<span className="magnitude-selector__label">{labels[channel]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -317,7 +344,9 @@ function GainSection({
|
||||
const [showOutputStages, setShowOutputStages] = useState(false);
|
||||
|
||||
const [liveInputGains, setLiveInputGains] = useState<Partial<Record<InputChannel, number>>>({});
|
||||
const [liveOutputGains, setLiveOutputGains] = useState<Partial<Record<OutputChannel, number>>>({});
|
||||
const [liveOutputGains, setLiveOutputGains] = useState<Partial<Record<OutputChannel, number>>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const setLiveInputGain = (channel: InputChannel, value: number | null) => {
|
||||
setLiveInputGains((prev) => {
|
||||
@@ -381,7 +410,11 @@ function GainSection({
|
||||
inputColors,
|
||||
showInputStages,
|
||||
(channel) =>
|
||||
getInputChannelCurves(dsp.state.current_config.input_states[channel], freqs, liveInputGains[channel])
|
||||
getInputChannelCurves(
|
||||
dsp.state.current_config.input_states[channel],
|
||||
freqs,
|
||||
liveInputGains[channel]
|
||||
)
|
||||
);
|
||||
|
||||
const outputCurves = buildCurvesForChannels(
|
||||
@@ -390,13 +423,17 @@ function GainSection({
|
||||
outputColors,
|
||||
showOutputStages,
|
||||
(channel) =>
|
||||
getOutputChannelCurves(dsp.state.current_config.output_states[channel], freqs, liveOutputGains[channel])
|
||||
getOutputChannelCurves(
|
||||
dsp.state.current_config.output_states[channel],
|
||||
freqs,
|
||||
liveOutputGains[channel]
|
||||
)
|
||||
);
|
||||
|
||||
const { yMin: inputYMin, yMax: inputYMax } = computeYAxisBounds(
|
||||
inputCurves,
|
||||
GRAPH_Y_MIN,
|
||||
GRAPH_Y_MAX,
|
||||
12,
|
||||
Y_AXIS_STEP
|
||||
);
|
||||
const { yMin: outputYMin, yMax: outputYMax } = computeYAxisBounds(
|
||||
@@ -411,14 +448,16 @@ function GainSection({
|
||||
<div className="gain-linear-graph-section">
|
||||
<div className="gain-graph-panel">
|
||||
<div className="gain-graph-title-row">
|
||||
<div className="gain-graph-title">Input Response</div>
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={showInputStages ? 'active' : ''}
|
||||
onClick={() => setShowInputStages((v) => !v)}
|
||||
>
|
||||
Show stages
|
||||
</Button>
|
||||
<div className="gain-graph-title-line">
|
||||
<div className="gain-graph-title">Input Response</div>
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={`gain-stages-toggle ${showInputStages ? 'active-info' : ''}`}
|
||||
onClick={() => setShowInputStages((v) => !v)}
|
||||
>
|
||||
Show stages
|
||||
</Button>
|
||||
</div>
|
||||
<ChannelSelector
|
||||
channels={INPUT_ORDER}
|
||||
labels={inputLabels}
|
||||
@@ -438,7 +477,18 @@ function GainSection({
|
||||
yLabel="dB"
|
||||
showGrid={true}
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => Math.round(v).toString()}
|
||||
formatY={(v) => {
|
||||
// Always label 0.
|
||||
if (v === 0) return '0';
|
||||
|
||||
// Positive side: label every 12 dB.
|
||||
if (v > 0) {
|
||||
return v % 12 === 0 ? `+${Math.round(v)}` : '';
|
||||
}
|
||||
|
||||
// Negative side: label every 15 dB.
|
||||
return Math.abs(v) % 15 === 0 ? Math.round(v).toString() : '';
|
||||
}}
|
||||
draw={({ ctx, toPx }) => {
|
||||
drawCurves({
|
||||
ctx,
|
||||
@@ -455,14 +505,17 @@ function GainSection({
|
||||
|
||||
<div className="gain-graph-panel">
|
||||
<div className="gain-graph-title-row">
|
||||
<div className="gain-graph-title">Output Response</div>
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={showOutputStages ? 'active' : ''}
|
||||
onClick={() => setShowOutputStages((v) => !v)}
|
||||
>
|
||||
Show stages
|
||||
</Button>
|
||||
<div className="gain-graph-title-line">
|
||||
<div className="gain-graph-title">Output Response</div>
|
||||
<Button
|
||||
variant={showOutputStages ? 'outline' : 'ghost'}
|
||||
className="gain-stages-toggle"
|
||||
onClick={() => setShowOutputStages((v) => !v)}
|
||||
>
|
||||
Show stages
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ChannelSelector
|
||||
channels={OUTPUT_ORDER}
|
||||
labels={outputLabels}
|
||||
@@ -535,4 +588,4 @@ function GainSection({
|
||||
);
|
||||
}
|
||||
|
||||
export default GainSection;
|
||||
export default GainSection;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: 40%;
|
||||
height: var(--dsp-control-heigth);
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -20,13 +20,18 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.gate-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gate-panel {
|
||||
@@ -104,6 +109,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.gate-graph-panel {
|
||||
|
||||
@@ -15,13 +15,6 @@ const GATE_PARAMS = [
|
||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||
] as const;
|
||||
|
||||
const INPUT_METER_INDEX: Record<InputChannel, number> = {
|
||||
[InputChannel.InA]: 0,
|
||||
[InputChannel.InB]: 1,
|
||||
[InputChannel.InC]: 2,
|
||||
[InputChannel.InD]: 3,
|
||||
} as const;
|
||||
|
||||
type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
|
||||
|
||||
type GateValues = {
|
||||
@@ -139,8 +132,8 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
<LinearGraph
|
||||
xMin={envelope.t0}
|
||||
xMax={envelope.t5}
|
||||
yMin={-90}
|
||||
yMax={0}
|
||||
yMin={-95}
|
||||
yMax={5}
|
||||
xLabel="ms"
|
||||
yLabel="dB"
|
||||
formatX={(v) => Math.round(v).toString()}
|
||||
@@ -185,9 +178,14 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
||||
<div className="gate-graph__meters">
|
||||
{inputs.map((input) => {
|
||||
const inputState = dsp.state.current_config.input_states[input];
|
||||
const idx = INPUT_METER_INDEX[input];
|
||||
const meter = dsp.meters.channels[idx];
|
||||
return <MeterBar key={input} label={inputState.name} meter={meter} />;
|
||||
|
||||
return (
|
||||
<MeterBar
|
||||
key={input}
|
||||
label={inputState.name}
|
||||
meter={dsp.input_meters.meters[input]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,13 +19,18 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.geq-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.geq-band-panel {
|
||||
@@ -112,6 +117,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.geq-graph-panel {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
GEQ_BANDS_LABEL,
|
||||
GEQ_CURVE_Q,
|
||||
} from '../../constants';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import './GeqSection.css';
|
||||
import { getGeqCurvePoints } from '../../utils/graph/geq';
|
||||
import { logSpace } from '../../utils/graph/curve';
|
||||
@@ -175,7 +175,7 @@ function GeqActions({
|
||||
<div className="geq-channel-selector">
|
||||
<Button
|
||||
variant="toggle"
|
||||
className={bypass ? 'active-info' : ''}
|
||||
className={bypass ? 'active-warning' : ''}
|
||||
onClick={onBypass}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -201,7 +201,7 @@ function GeqActions({
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */}
|
||||
{resetting ? 'Resetting…' : 'Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -219,6 +219,20 @@ function GeqSection({
|
||||
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
|
||||
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResetConfirmOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsResetConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isResetConfirmOpen]);
|
||||
|
||||
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
|
||||
setLiveGeq((prev) => ({
|
||||
@@ -245,6 +259,10 @@ function GeqSection({
|
||||
setGeqBypass(activeChannel, !activeGeq.bypass);
|
||||
};
|
||||
|
||||
const handleResetClick = () => {
|
||||
setIsResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
// Sequential per-band reset, since hardware has no bulk reset command.
|
||||
const handleReset = async (channel: InputChannel) => {
|
||||
if (resetting) return;
|
||||
@@ -261,6 +279,11 @@ function GeqSection({
|
||||
setResetting(false);
|
||||
};
|
||||
|
||||
const confirmReset = async () => {
|
||||
setIsResetConfirmOpen(false);
|
||||
await handleReset(activeChannel);
|
||||
};
|
||||
|
||||
const activeInputState = dsp.state.current_config.input_states[activeChannel];
|
||||
const activeGeq = activeInputState.geq;
|
||||
const activeLiveBands = liveGeq[activeChannel];
|
||||
@@ -306,12 +329,33 @@ function GeqSection({
|
||||
bypass={activeGeq.bypass}
|
||||
resetting={resetting}
|
||||
onBypass={handleBypassClick}
|
||||
onReset={() => handleReset(activeChannel)}
|
||||
onReset={handleResetClick}
|
||||
/>
|
||||
</ChannelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isResetConfirmOpen && (
|
||||
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Reset {activeInputState.name} GEQ?</h3>
|
||||
<p>
|
||||
This will set all GEQ bands on {activeInputState.name} back to 0 dB. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="confirm-modal__actions">
|
||||
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,18 +18,23 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.limiter-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.limiter-panel {
|
||||
position: relative;
|
||||
height: 10;
|
||||
height: 100%;
|
||||
|
||||
aspect-ratio: 0.5;
|
||||
flex: 0 0 auto;
|
||||
@@ -116,6 +121,9 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-panel);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.limiter-graph-panel {
|
||||
|
||||
@@ -14,20 +14,6 @@ const LIMITER_SLIDER_PARAMS = [
|
||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||
] as const;
|
||||
|
||||
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
|
||||
// Out1 -> 4, Out2 -> 5, etc. Same mapping as CompressorSection — adjust if
|
||||
// dsp.meters.channels ordering differs.
|
||||
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
|
||||
[OutputChannel.Out1]: 4,
|
||||
[OutputChannel.Out2]: 5,
|
||||
[OutputChannel.Out3]: 6,
|
||||
[OutputChannel.Out4]: 7,
|
||||
[OutputChannel.Out5]: 8,
|
||||
[OutputChannel.Out6]: 9,
|
||||
[OutputChannel.Out7]: 10,
|
||||
[OutputChannel.Out8]: 11,
|
||||
} as const;
|
||||
|
||||
type LimiterParamKey = 'threshold' | 'attack' | 'release';
|
||||
|
||||
type LimiterValues = {
|
||||
@@ -206,10 +192,13 @@ function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimite
|
||||
<div className="limiter-graph__meters">
|
||||
{outputs.map((output) => {
|
||||
const outputState = dsp.state.current_config.output_states[output];
|
||||
const idx = OUTPUT_METER_INDEX[output];
|
||||
|
||||
return (
|
||||
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
|
||||
<MeterBar
|
||||
key={output}
|
||||
label={outputState.name}
|
||||
meter={dsp.output_meters.meters[output]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -19,19 +19,25 @@
|
||||
z-index: 20;
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.matrix-panels {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.matrix-panel {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
aspect-ratio: 0.6;
|
||||
aspect-ratio: 0.5;
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
@@ -84,10 +90,30 @@
|
||||
padding-inline: 3cqh;
|
||||
}
|
||||
|
||||
.matrix-panel__row .choice-stepper {
|
||||
align-self: center;
|
||||
aspect-ratio: 3 / 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.matrix-flow {
|
||||
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2);
|
||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.matrix-gain-stepper .choice-stepper__input {
|
||||
font-size: 2.8cqh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.matrix-gain-stepper .choice-stepper__arrows {
|
||||
flex: 0 0 20%;
|
||||
width: 20%;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from '../../constants';
|
||||
import { SidebarId } from '../../constants';
|
||||
import SignalFlowMatrix from '../../../../components/SignalFlowMatrix/SignalFlowMatrix';
|
||||
import './MatrixSection.css';
|
||||
import { useState } from 'react';
|
||||
import './Matrixsection.css';
|
||||
|
||||
const GAIN_VALUES: number[] = (() => {
|
||||
const values: number[] = [];
|
||||
@@ -39,6 +40,12 @@ function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
|
||||
return getMatrixRoutes(dsp, output).connected;
|
||||
}
|
||||
|
||||
function snapToGain(g: number): number {
|
||||
return GAIN_VALUES.reduce((best, v) =>
|
||||
Math.abs(v - g) < Math.abs(best - g) ? v : best
|
||||
);
|
||||
}
|
||||
|
||||
function MatrixSection({
|
||||
dsp,
|
||||
setMatrixRouting,
|
||||
@@ -68,6 +75,21 @@ function MatrixSection({
|
||||
void setMatrixGain(input, output, gain);
|
||||
};
|
||||
|
||||
const [liveGains, setLiveGains] = useState<Record<string, number>>({});
|
||||
|
||||
// value = number while wheeling, null once the commit is done
|
||||
const previewGain = (input: InputChannel, output: OutputChannel, value: number | null) => {
|
||||
const key = `${input}-${output}`;
|
||||
setLiveGains((prev) => {
|
||||
if (value === null) {
|
||||
if (!(key in prev)) return prev;
|
||||
const { [key]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [key]: value };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="matrix-flow">
|
||||
@@ -76,6 +98,7 @@ function MatrixSection({
|
||||
setMatrixRouting={setMatrixRouting}
|
||||
setMatrixGain={setMatrixGain}
|
||||
setActiveSidebar={setActiveSidebar}
|
||||
onGainPreview={previewGain}
|
||||
/>
|
||||
</div>
|
||||
<div className="matrix-section">
|
||||
@@ -90,7 +113,7 @@ function MatrixSection({
|
||||
<div className="matrix-panel__rows">
|
||||
{INPUT_ORDER.map((input) => {
|
||||
const connected = isRouted(dsp, output, input);
|
||||
const gain = getLinkGain(dsp, output, input);
|
||||
const gain = liveGains[`${input}-${output}`] ?? getLinkGain(dsp, output, input);
|
||||
|
||||
return (
|
||||
<div className="matrix-panel__row" key={String(input)}>
|
||||
@@ -101,8 +124,8 @@ function MatrixSection({
|
||||
fontSize: '4cqh',
|
||||
padding: 0,
|
||||
minHeight: 0,
|
||||
marginTop: '2cqh',
|
||||
marginBottom: '2cqh',
|
||||
marginTop: '3cqh',
|
||||
marginBottom: '3cqh',
|
||||
}}
|
||||
onClick={() => handleToggle(output, input)}
|
||||
>
|
||||
@@ -110,7 +133,8 @@ function MatrixSection({
|
||||
</Button>
|
||||
|
||||
<ChoiceStepper
|
||||
value={gain}
|
||||
className="matrix-gain-stepper"
|
||||
value={snapToGain(gain)}
|
||||
values={GAIN_VALUES}
|
||||
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||
onChange={(g) => handleGainChange(output, input, g)}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
.copy-channel-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: copy-channel-fade-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes copy-channel-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-channel-modal {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
|
||||
animation: copy-channel-scale-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes copy-channel-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-channel-modal__header {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.copy-channel-modal__header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copy-channel-modal__header p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Source -> destination */
|
||||
|
||||
.copy-channel-modal__transfer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.copy-channel-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copy-channel-modal__field label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: var(--card-control-height);
|
||||
color: var(--text-muted);
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow svg {
|
||||
width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
}
|
||||
|
||||
.copy-channel-modal__summary {
|
||||
margin: var(--space-3) 0 var(--space-5);
|
||||
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer actions */
|
||||
|
||||
.copy-channel-modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.copy-channel-modal__transfer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow {
|
||||
height: auto;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||