Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e68e73d3d5 | ||
|
|
a5669805c8 | ||
|
|
cef7336517 | ||
|
|
50a3c1a1e5 | ||
|
|
684f5ce866 | ||
|
|
80da40f678 | ||
|
|
3cca2f41f4 | ||
|
|
6511d9d007 | ||
|
|
d5a2afdea5 | ||
|
|
24830172ae | ||
|
|
fd7dd89f55 | ||
|
|
dac6c729cd | ||
|
|
d48155683b | ||
|
|
e1d6398d57 | ||
|
|
4aae47cd27 | ||
|
|
61f821c3e9 | ||
|
|
b74857d7f6 | ||
|
|
d3a783f77f | ||
|
|
f965779373 | ||
|
|
b0d4e372cb | ||
|
|
e893cab082 | ||
|
|
10b890b5ae | ||
|
|
e05d589311 | ||
|
|
00b6721c42 |
@@ -9,3 +9,9 @@ This template should help get you started developing with Tauri, React and Types
|
|||||||
# TODO
|
# TODO
|
||||||
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
|
- 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
|
||||||
|
- Ma-trix gain block sometimes
|
||||||
|
- Q out of range
|
||||||
|
|
||||||
|
- Check end to end behavioour
|
||||||
@@ -175,7 +175,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -225,6 +225,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -397,7 +403,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -453,6 +459,17 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -543,6 +560,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -618,9 +644,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"darling_macro",
|
"darling_macro",
|
||||||
@@ -628,26 +654,26 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_core"
|
name = "darling_core"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -661,6 +687,37 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -796,12 +853,13 @@ dependencies = [
|
|||||||
name = "dsp_thomann"
|
name = "dsp_thomann"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1255,6 +1313,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1762,6 +1821,60 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2005,7 +2118,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2493,6 +2606,21 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2606,6 +2734,23 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2629,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2649,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2883,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2918,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2941,16 +3086,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
|
"jiff",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -2961,14 +3107,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.21.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3009,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3199,9 +3345,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.2"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3300,9 +3446,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.5"
|
version = "2.11.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3339,7 +3485,7 @@ dependencies = [
|
|||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
@@ -3390,7 +3536,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3429,9 +3575,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -3443,7 +3589,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -3467,7 +3613,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -3530,7 +3676,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
@@ -3582,11 +3728,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.20",
|
"thiserror-impl 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3602,13 +3748,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3919,7 +4065,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4279,7 +4425,7 @@ version = "0.38.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -4714,7 +4860,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.21",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2.11.6", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2.5.5"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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 |
@@ -46,7 +46,6 @@ pub struct DSP408Snapshot {
|
|||||||
pub state: types::DSPState,
|
pub state: types::DSPState,
|
||||||
pub input_meters: types::InputMeters,
|
pub input_meters: types::InputMeters,
|
||||||
pub output_meters: types::OutputMeters,
|
pub output_meters: types::OutputMeters,
|
||||||
pub mode: types::DSPMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -104,7 +103,6 @@ pub async fn get_dsp_snapshot(
|
|||||||
state: device.dsp.state().clone(),
|
state: device.dsp.state().clone(),
|
||||||
input_meters: device.dsp.input_meters().clone(),
|
input_meters: device.dsp.input_meters().clone(),
|
||||||
output_meters: device.dsp.output_meters().clone(),
|
output_meters: device.dsp.output_meters().clone(),
|
||||||
mode: device.dsp.mode(),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -180,7 +178,7 @@ pub async fn store_current_configuration(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x23` -- TODO
|
// Command `0x23`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn factory_reset(
|
pub async fn factory_reset(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -207,7 +205,7 @@ pub async fn set_current_preset_name(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x2A` -- TODO
|
// Command `0x2A`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn copy_channel(
|
pub async fn copy_channel(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -224,7 +222,34 @@ pub async fn copy_channel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x2D` -- TODO
|
// Command `0x2D` -- TODO
|
||||||
// Command `0x2F` -- 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`
|
// Command `0x30`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -372,7 +397,7 @@ pub async fn set_delay(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x39` -- TODO
|
// Command `0x39`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_input_source(
|
pub async fn set_input_source(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -403,7 +428,7 @@ pub async fn set_matrix_routing(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x3B` -- TODO
|
// Command `0x3B`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn link_channels(
|
pub async fn link_channels(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub fn run() {
|
|||||||
commands::factory_reset, // 0x23
|
commands::factory_reset, // 0x23
|
||||||
commands::set_current_preset_name, // 0x26
|
commands::set_current_preset_name, // 0x26
|
||||||
commands::copy_channel, // 0x2A
|
commands::copy_channel, // 0x2A
|
||||||
|
commands::unlock_device, // Command `0x2D`
|
||||||
|
commands::lock_device, // Command `0x2F`
|
||||||
commands::set_compressor, // 0x30
|
commands::set_compressor, // 0x30
|
||||||
commands::set_low_pass, // 0x31
|
commands::set_low_pass, // 0x31
|
||||||
commands::set_high_pass, // 0x32
|
commands::set_high_pass, // 0x32
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "thomann-dsp-app",
|
"title": "Thomann DSP Controller",
|
||||||
"width": 1600,
|
"width": 1600,
|
||||||
"height": 950,
|
"height": 950,
|
||||||
"minWidth": 1200,
|
"minWidth": 1200,
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"./icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"./icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"./icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"./icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"./icons/icon.ico"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { DSP, AppPage } from './types';
|
import { DSP, AppPage, isConnected } from './types';
|
||||||
import TopBar from './components/Topbar/Topbar';
|
import TopBar from './components/Topbar/Topbar';
|
||||||
import DSPPage from './pages/DspPage/DspPage';
|
import DSPPage from './pages/DspPage/DspPage';
|
||||||
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
||||||
@@ -17,7 +17,7 @@ function App() {
|
|||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const [page, setPage] = useState<AppPage>('home');
|
const [page, setPage] = useState<AppPage>('home');
|
||||||
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
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 activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
||||||
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
|
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
|
||||||
|
|||||||
@@ -84,3 +84,152 @@ export function TextIcon({
|
|||||||
</svg>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,125 @@
|
|||||||
|
/* ChoiceSelect.css */
|
||||||
.choice-select {
|
.choice-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
|
||||||
|
color: var(--accent-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-select[data-disabled] {
|
.choice-select[data-disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-select__input {
|
.choice-select__trigger {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
height: var(--card-control-height);
|
display: flex;
|
||||||
padding: 0 var(--space-6) 0 var(--space-3);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
padding-inline: 5%;
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--font-sm);
|
font-size: 90%;
|
||||||
|
|
||||||
appearance: none;
|
color: inherit;
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition: border-color var(--dur-base) var(--ease-standard);
|
transition: border-color var(--dur-base) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-select__input:focus {
|
.choice-select__trigger:hover,
|
||||||
|
.choice-select__trigger--open {
|
||||||
border-color: var(--accent-brand);
|
border-color: var(--accent-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-select__input:disabled {
|
.choice-select__trigger:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.choice-select__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.choice-select__chevron {
|
.choice-select__chevron {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 0;
|
|
||||||
right: 5%;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
width: 4cqh;
|
|
||||||
padding-top: 3%;
|
|
||||||
padding-bottom: 3%;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
pointer-events: none;
|
width: 4cqh;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
color: var(--accent-brand);
|
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 {
|
.choice-select__chevron svg {
|
||||||
width: 100%;
|
width: 50%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
display: block;
|
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 */
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// ChoiceSelect.tsx
|
// ChoiceSelect.tsx
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { TriangleDownIcon } from '../../assets/icons/Triangle';
|
import { TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||||
import './ChoiceSelect.css';
|
import './ChoiceSelect.css';
|
||||||
|
|
||||||
@@ -13,6 +15,11 @@ type ChoiceSelectProps<T extends string> = {
|
|||||||
style?: React.CSSProperties;
|
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>({
|
function ChoiceSelect<T extends string>({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
@@ -23,50 +30,201 @@ function ChoiceSelect<T extends string>({
|
|||||||
className = '',
|
className = '',
|
||||||
style,
|
style,
|
||||||
}: ChoiceSelectProps<T>) {
|
}: 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 step = (direction: 1 | -1) => {
|
||||||
const currentIndex = options.indexOf(value);
|
const currentIndex = options.indexOf(value);
|
||||||
|
|
||||||
if (currentIndex === -1 || options.length === 0) return;
|
if (currentIndex === -1 || options.length === 0) return;
|
||||||
|
|
||||||
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
||||||
|
|
||||||
onChange(options[nextIndex]);
|
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 (
|
return (
|
||||||
<div className={`choice-select ${className}`} data-disabled={disabled || undefined}>
|
<div
|
||||||
<select
|
className={`choice-select ${className}`}
|
||||||
|
data-disabled={disabled || undefined}
|
||||||
|
ref={rootRef}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<button
|
||||||
id={id}
|
id={id}
|
||||||
className="choice-select__input"
|
type="button"
|
||||||
style={style}
|
ref={triggerRef}
|
||||||
value={value}
|
className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
|
||||||
disabled={disabled}
|
onClick={() => (open ? setOpen(false) : openList())}
|
||||||
onChange={(e) => {
|
onKeyDown={handleKeyDown}
|
||||||
onChange(e.target.value as T);
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
if (e.deltaY < 0) step(-1);
|
||||||
if (e.deltaY < 0) {
|
else if (e.deltaY > 0) step(1);
|
||||||
step(1);
|
|
||||||
} else if (e.deltaY > 0) {
|
|
||||||
step(-1);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
<span className="choice-select__label">{formatLabel(value)}</span>
|
||||||
<option key={option} value={option}>
|
<span className="choice-select__chevron" aria-hidden="true">
|
||||||
{formatLabel(option)}
|
<TriangleDownIcon />
|
||||||
</option>
|
</span>
|
||||||
))}
|
</button>
|
||||||
</select>
|
|
||||||
|
|
||||||
<span className="choice-select__chevron" aria-hidden="true">
|
{open &&
|
||||||
<TriangleDownIcon />
|
coords &&
|
||||||
</span>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.choice-stepper[data-disabled] {
|
.choice-stepper[data-disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-stepper__input:disabled {
|
.choice-stepper__input:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.choice-stepper[data-disabled] .choice-stepper__input,
|
||||||
|
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
|
||||||
|
color: var(--border-hairline);
|
||||||
|
}
|
||||||
|
|||||||
@@ -365,3 +365,54 @@
|
|||||||
.app-root--sidebar-expanded .stage--with-sidebar {
|
.app-root--sidebar-expanded .stage--with-sidebar {
|
||||||
margin-left: var(--sidebar-width-expanded);
|
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;
|
id: T;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidebarAppOption = {
|
export type SidebarAppOption = {
|
||||||
@@ -17,11 +18,75 @@ function SidebarButton<T extends string>({
|
|||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onRename,
|
||||||
}: {
|
}: {
|
||||||
item: SidebarItem<T>;
|
item: SidebarItem<T>;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onSelect: () => void;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
@@ -31,7 +96,14 @@ function SidebarButton<T extends string>({
|
|||||||
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
|
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
|
||||||
>
|
>
|
||||||
<span className="sidebar-icon">{item.icon}</span>
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -230,6 +302,7 @@ export function Sidebar<T extends string>({
|
|||||||
selectedAppId,
|
selectedAppId,
|
||||||
onAppChange,
|
onAppChange,
|
||||||
onAppRename,
|
onAppRename,
|
||||||
|
onItemRename,
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
@@ -241,6 +314,7 @@ export function Sidebar<T extends string>({
|
|||||||
selectedAppId?: string;
|
selectedAppId?: string;
|
||||||
onAppChange: (id: string) => void;
|
onAppChange: (id: string) => void;
|
||||||
onAppRename: (name: string) => void;
|
onAppRename: (name: string) => void;
|
||||||
|
onItemRename?: (id: T, name: string) => void;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -298,6 +372,9 @@ export function Sidebar<T extends string>({
|
|||||||
item={item}
|
item={item}
|
||||||
active={activeId === item.id}
|
active={activeId === item.id}
|
||||||
onSelect={() => onSelect(item.id)}
|
onSelect={() => onSelect(item.id)}
|
||||||
|
onRename={
|
||||||
|
item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { DSP, AppPage } from '../../types';
|
import { DSP, AppPage, isConnected } from '../../types';
|
||||||
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
|
||||||
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
import { PlusIcon } from '../../assets/icons/PlusIcon';
|
||||||
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
import { CrownIcon } from '../../assets/icons/CrownIcon';
|
||||||
@@ -24,6 +24,7 @@ function Tab({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -33,8 +34,9 @@ function Tab({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onConnect: (dsp: DSP) => void;
|
onConnect: (dsp: DSP) => void;
|
||||||
onDisconnect: (dsp: DSP) => void;
|
onDisconnect: (dsp: DSP) => void;
|
||||||
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const isConnected = dsp.status === 'connected';
|
const isDspConnected = isConnected(dsp);
|
||||||
|
|
||||||
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -58,24 +60,36 @@ function Tab({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tab-led"
|
className="tab-led"
|
||||||
aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
|
aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (isConnected) {
|
onSelect();
|
||||||
|
|
||||||
|
if (isDspConnected) {
|
||||||
onDisconnect(dsp);
|
onDisconnect(dsp);
|
||||||
} else {
|
} else {
|
||||||
onConnect(dsp);
|
onConnect(dsp);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Led status={dsp.status} />
|
<Led
|
||||||
|
status={
|
||||||
|
dsp.isConnecting
|
||||||
|
? 'connecting'
|
||||||
|
: isConnected(dsp)
|
||||||
|
? 'connected'
|
||||||
|
: 'disconnected'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="tab-name" title={dsp.name}>
|
<span className="tab-name" title={dsp.name}>
|
||||||
{dsp.name}
|
{dsp.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tab-close"
|
className="tab-close"
|
||||||
|
|||||||
@@ -146,3 +146,26 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: 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;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
/** Greys out the slider and blocks all interaction. */
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultStep: StepFn = () => 0.1;
|
const defaultStep: StepFn = () => 0.1;
|
||||||
@@ -174,6 +177,7 @@ export default function VerticalSlider({
|
|||||||
formatValue,
|
formatValue,
|
||||||
showTicks = true,
|
showTicks = true,
|
||||||
scale = 'linear',
|
scale = 'linear',
|
||||||
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
}: VerticalSliderProps) {
|
}: VerticalSliderProps) {
|
||||||
@@ -240,6 +244,26 @@ export default function VerticalSlider({
|
|||||||
return () => observer.disconnect();
|
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(() => {
|
const decimals = useMemo(() => {
|
||||||
if (listValues) return Math.min(3, getDecimals(displayValue));
|
if (listValues) return Math.min(3, getDecimals(displayValue));
|
||||||
return Math.min(3, getDecimals(step(displayValue)));
|
return Math.min(3, getDecimals(step(displayValue)));
|
||||||
@@ -273,6 +297,8 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -307,6 +333,8 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleKeyUp() {
|
async function handleKeyUp() {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
if (keyboardValue.current == null) return;
|
if (keyboardValue.current == null) return;
|
||||||
|
|
||||||
const next = keyboardValue.current;
|
const next = keyboardValue.current;
|
||||||
@@ -323,6 +351,7 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWheel(e: WheelEvent) {
|
function handleWheel(e: WheelEvent) {
|
||||||
|
if (disabled) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
wheelEditing.current = true;
|
wheelEditing.current = true;
|
||||||
@@ -393,6 +422,8 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
|
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
const target = e.currentTarget;
|
const target = e.currentTarget;
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
target.setPointerCapture(e.pointerId);
|
target.setPointerCapture(e.pointerId);
|
||||||
@@ -427,6 +458,11 @@ export default function VerticalSlider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function commitEditing() {
|
async function commitEditing() {
|
||||||
|
if (disabled) {
|
||||||
|
setEditingText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editingText !== null && editingText !== '' && editingText !== '-') {
|
if (editingText !== null && editingText !== '' && editingText !== '-') {
|
||||||
const parsed = Number(editingText);
|
const parsed = Number(editingText);
|
||||||
|
|
||||||
@@ -452,7 +488,10 @@ export default function VerticalSlider({
|
|||||||
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
const percent = valueToPercent(displayValue, min, max, scale, listValues);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
@@ -468,7 +507,12 @@ export default function VerticalSlider({
|
|||||||
<div
|
<div
|
||||||
className="slider"
|
className="slider"
|
||||||
ref={sliderRef}
|
ref={sliderRef}
|
||||||
tabIndex={0}
|
role="slider"
|
||||||
|
aria-disabled={disabled}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={displayValue}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
onPointerDown={startDrag}
|
onPointerDown={startDrag}
|
||||||
@@ -494,6 +538,7 @@ export default function VerticalSlider({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
disabled={disabled}
|
||||||
value={editingText ?? formattedValue}
|
value={editingText ?? formattedValue}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
|
onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
PEQ_FILTERS_LABEL,
|
PEQ_FILTERS_LABEL,
|
||||||
PEQ_Q_RANGES,
|
PEQ_Q_RANGES,
|
||||||
PEAK_Q_VALUES,
|
PEAK_Q_VALUES,
|
||||||
|
PEQ_Q_UNUSED,
|
||||||
|
PEQ_GAIN_USED,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
|
import { Channel, PEQBand, PEQFilter, PEQ, Crossover } from '../../types';
|
||||||
import { DSP408 as DSP } from '../../../../types';
|
import { DSP408 as DSP } from '../../../../types';
|
||||||
import { CrossoverFilter } from '../../types';
|
import { CrossoverFilter } from '../../types';
|
||||||
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
|
import { LinearGraph, LinearGraphHandle } from '../../../../components/LinearGraph/LinearGraph';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import './ChannelSection.css';
|
import './ChannelSection.css';
|
||||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
@@ -128,6 +130,8 @@ function PeqPanelSliders({
|
|||||||
onLiveValue: (value: PEQ) => void;
|
onLiveValue: (value: PEQ) => void;
|
||||||
}) {
|
}) {
|
||||||
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
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 commit = (overrides: Partial<PEQ>) => {
|
const commit = (overrides: Partial<PEQ>) => {
|
||||||
const next = { ...peq, ...overrides };
|
const next = { ...peq, ...overrides };
|
||||||
@@ -168,6 +172,7 @@ function PeqPanelSliders({
|
|||||||
value={peq.q}
|
value={peq.q}
|
||||||
onChange={(value) => commit({ q: value })}
|
onChange={(value) => commit({ q: value })}
|
||||||
onInput={(value) => onLiveValue({ ...peq, q: value })}
|
onInput={(value) => onLiveValue({ ...peq, q: value })}
|
||||||
|
disabled={qDisabled}
|
||||||
style={{ width: '100%', flex: '1 1 auto' }}
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -184,6 +189,7 @@ function PeqPanelSliders({
|
|||||||
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
|
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
|
||||||
onChange={(value) => commit({ gain: value })}
|
onChange={(value) => commit({ gain: value })}
|
||||||
onInput={(value) => onLiveValue({ ...peq, gain: value })}
|
onInput={(value) => onLiveValue({ ...peq, gain: value })}
|
||||||
|
disabled={gainDisabled}
|
||||||
style={{ width: '100%', flex: '1 1 auto' }}
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -335,6 +341,9 @@ function PeqBandList({
|
|||||||
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||||
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
|
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
|
||||||
|
|
||||||
|
const qDisabled = PEQ_Q_UNUSED.has(peq.filter_type);
|
||||||
|
const gainDisabled = !PEQ_GAIN_USED.has(peq.filter_type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
||||||
@@ -370,6 +379,7 @@ function PeqBandList({
|
|||||||
value={peq.q}
|
value={peq.q}
|
||||||
values={qValues}
|
values={qValues}
|
||||||
formatLabel={(v) => v.toFixed(2)}
|
formatLabel={(v) => v.toFixed(2)}
|
||||||
|
disabled={qDisabled}
|
||||||
onChange={(value) => commit(band, { q: value })}
|
onChange={(value) => commit(band, { q: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -380,6 +390,7 @@ function PeqBandList({
|
|||||||
max={12}
|
max={12}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||||
|
disabled={gainDisabled}
|
||||||
onChange={(value) => commit(band, { gain: value })}
|
onChange={(value) => commit(band, { gain: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -441,6 +452,7 @@ function ChannelSection({
|
|||||||
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
|
||||||
const [graphView, setGraphView] = useState<GraphView>('both');
|
const [graphView, setGraphView] = useState<GraphView>('both');
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// Live values
|
// Live values
|
||||||
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
const [liveHpFreq, setLiveHpFreq] = useState<number>(FREQUENCY_MIN);
|
||||||
@@ -461,6 +473,23 @@ function ChannelSection({
|
|||||||
|
|
||||||
const bandsOrder = isInput ? PEQ_BANDS_ORDER.slice(0, -1) : PEQ_BANDS_ORDER;
|
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 () => {
|
const handleResetPeq = async () => {
|
||||||
if (resetting) return;
|
if (resetting) return;
|
||||||
setResetting(true);
|
setResetting(true);
|
||||||
@@ -476,6 +505,11 @@ function ChannelSection({
|
|||||||
setResetting(false);
|
setResetting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmResetPeq = async () => {
|
||||||
|
setIsResetConfirmOpen(false);
|
||||||
|
await handleResetPeq();
|
||||||
|
};
|
||||||
|
|
||||||
const handleBypassClick = () => {
|
const handleBypassClick = () => {
|
||||||
setPeqBypass(channel, !ActivePeq.bypass);
|
setPeqBypass(channel, !ActivePeq.bypass);
|
||||||
};
|
};
|
||||||
@@ -681,11 +715,32 @@ function ChannelSection({
|
|||||||
bypass={ActivePeq.bypass}
|
bypass={ActivePeq.bypass}
|
||||||
resetting={resetting}
|
resetting={resetting}
|
||||||
onBypass={handleBypassClick}
|
onBypass={handleBypassClick}
|
||||||
onReset={handleResetPeq}
|
onReset={handleResetClick}
|
||||||
/>
|
/>
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DSP } from '../../../../types';
|
import { DSP, isConnected } from '../../../../types';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
import Card from '../../../../components/Card/Card';
|
import Card from '../../../../components/Card/Card';
|
||||||
import Led from '../../../../components/Led/Led';
|
import Led from '../../../../components/Led/Led';
|
||||||
@@ -14,12 +14,13 @@ function ConnectionPanel({
|
|||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const statusLabel =
|
|
||||||
dsp.status === 'connected'
|
const statusLabel =
|
||||||
? 'Connected'
|
isConnected(dsp)
|
||||||
: dsp.status === 'connecting'
|
? 'Connected'
|
||||||
? 'Connecting…'
|
: dsp.isConnecting
|
||||||
: 'Disconnected';
|
? 'Connecting…'
|
||||||
|
: 'Disconnected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="connection-section">
|
<div className="connection-section">
|
||||||
@@ -35,10 +36,10 @@ function ConnectionPanel({
|
|||||||
|
|
||||||
<div className="connection-panel__status">
|
<div className="connection-panel__status">
|
||||||
<span className="connection-panel__led">
|
<span className="connection-panel__led">
|
||||||
<Led status={dsp.status} />
|
<Led status={dsp.isConnecting ? "connecting" : isConnected(dsp) ? "connected" : "disconnected"} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
|
<span>{statusLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,13 +65,13 @@ function ConnectionPanel({
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="connection-panel__actions">
|
<div className="connection-panel__actions">
|
||||||
{dsp.status === 'connected' ? (
|
{isConnected(dsp) ? (
|
||||||
<Button variant="outline" onClick={onDisconnect}>
|
<Button variant="outline" onClick={onDisconnect}>
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
|
<Button variant="primary" onClick={onConnect} disabled={dsp.isConnecting}>
|
||||||
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
|
{dsp.isConnecting ? 'Connecting…' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,3 +29,9 @@
|
|||||||
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
|
||||||
left: var(--sidebar-width-expanded);
|
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 { 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 ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||||
import GainSection from '../GainSection/GainSection';
|
import GainSection from '../GainSection/GainSection';
|
||||||
import CompressorSection from '../CompressorSection/CompressorSection';
|
import CompressorSection from '../CompressorSection/CompressorSection';
|
||||||
@@ -11,91 +22,78 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
|
|||||||
import { InputChannel, OutputChannel } from '../../types';
|
import { InputChannel, OutputChannel } from '../../types';
|
||||||
import MatrixSection from '../Matrixsection/Matrixsection';
|
import MatrixSection from '../Matrixsection/Matrixsection';
|
||||||
import GeqSection from '../GeqSection/GeqSection';
|
import GeqSection from '../GeqSection/GeqSection';
|
||||||
import ChannelSection from '../InputSection/ChannelSection';
|
import ChannelSection from '../ChannelSection/ChannelSection';
|
||||||
import { DSP408Api } from '../../dsp408';
|
import { DSP408Api } from '../../dsp408';
|
||||||
import { DSP408 } from '../../../../types';
|
import { DSP408 } from '../../../../types';
|
||||||
import { SidebarId } from '../../constants';
|
import { SidebarId } from '../../constants';
|
||||||
import './DSP408Panel.css';
|
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 }) {
|
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||||
const [activeSidebar, setActiveSidebar] = useState<SidebarId>('overview');
|
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
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 [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>[] = [
|
const sidebarItems: SidebarItem<SidebarId>[] = [
|
||||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||||
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
{ id: 'gain', label: 'Gain', icon: <GainIcon /> },
|
||||||
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
{ id: 'gate', label: 'Gate', icon: <GateIcon /> },
|
||||||
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
|
{ id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
|
||||||
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
|
{ id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
|
||||||
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
|
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
|
||||||
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
|
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
|
||||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
|
{ id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
|
||||||
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
|
{ 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> },
|
{ 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> },
|
{ 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> },
|
{ 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: 'out1',
|
{ id: 'out2', label: outputStates[OutputChannel.Out2].name, icon: <TextIcon>Out 2</TextIcon>, editable: true },
|
||||||
label: `${outputStates[OutputChannel.Out1].name}`,
|
{ id: 'out3', label: outputStates[OutputChannel.Out3].name, icon: <TextIcon>Out 3</TextIcon>, editable: true },
|
||||||
icon: <TextIcon>Out 1</TextIcon>,
|
{ 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: 'out2',
|
{ id: 'out7', label: outputStates[OutputChannel.Out7].name, icon: <TextIcon>Out 7</TextIcon>, editable: true },
|
||||||
label: `${outputStates[OutputChannel.Out2].name}`,
|
{ id: 'out8', label: outputStates[OutputChannel.Out8].name, icon: <TextIcon>Out 8</TextIcon>, editable: true },
|
||||||
icon: <TextIcon>Out 2</TextIcon>,
|
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
|
||||||
},
|
|
||||||
{
|
|
||||||
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> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const presets = dsp.state?.presets.names
|
const presets = [
|
||||||
? [
|
{ id: 'f00', name: 'Factory Preset' },
|
||||||
{ id: 'f00', name: 'Factory Preset' },
|
...(dsp.state.presets.names.map((name, index) => ({
|
||||||
...dsp.state.presets.names.map((name, index) => ({
|
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||||
id: `u${String(index + 1).padStart(2, '0')}`,
|
name,
|
||||||
name,
|
})) ??
|
||||||
})),
|
Array.from({ length: 20 }, (_, index) => ({
|
||||||
]
|
id: `u${String(index + 1).padStart(2, '0')}`,
|
||||||
: [
|
name: 'Default Preset',
|
||||||
{ id: 'f00', name: 'Factory Preset' },
|
}))),
|
||||||
...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 onAppChange = async (id: string) => {
|
||||||
const presetIndex = presets.findIndex((preset) => preset.id === id);
|
const presetIndex = presets.findIndex((preset) => preset.id === id);
|
||||||
@@ -107,6 +105,43 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
setPresetLoading(false);
|
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 = () => {
|
const renderTab = () => {
|
||||||
switch (activeSidebar) {
|
switch (activeSidebar) {
|
||||||
case 'overview':
|
case 'overview':
|
||||||
@@ -147,93 +182,58 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
case 'geq':
|
case 'geq':
|
||||||
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
|
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
|
||||||
|
|
||||||
case 'ina':
|
case 'miscellaneous':
|
||||||
return (
|
return (
|
||||||
<ChannelSection
|
<MiscellaneousPanel
|
||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
channel={{ Input: InputChannel.InA }}
|
factoryReset={api.factoryReset}
|
||||||
setLowPass={api.setLowPass}
|
copyChannel={api.copyChannel}
|
||||||
setHighPass={api.setHighPass}
|
setInputSource={api.setInputSource}
|
||||||
setPeq={api.setPeqBand}
|
linkChannels={api.linkChannels}
|
||||||
setPeqBypass={api.setPEQBypass}
|
lockDevice={api.lockDevice}
|
||||||
|
setActiveSidebar={setActiveSidebar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'inb':
|
default: {
|
||||||
return (
|
const channel = channelMap[activeSidebar];
|
||||||
<ChannelSection
|
return channel ? renderChannel(channel) : null;
|
||||||
dsp={dsp}
|
}
|
||||||
channel={{ Input: InputChannel.InB }}
|
|
||||||
setLowPass={api.setLowPass}
|
|
||||||
setHighPass={api.setHighPass}
|
|
||||||
setPeq={api.setPeqBand}
|
|
||||||
setPeqBypass={api.setPEQBypass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'inc':
|
|
||||||
return (
|
|
||||||
<ChannelSection
|
|
||||||
dsp={dsp}
|
|
||||||
channel={{ Input: InputChannel.InC }}
|
|
||||||
setLowPass={api.setLowPass}
|
|
||||||
setHighPass={api.setHighPass}
|
|
||||||
setPeq={api.setPeqBand}
|
|
||||||
setPeqBypass={api.setPEQBypass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'ind':
|
|
||||||
return (
|
|
||||||
<ChannelSection
|
|
||||||
dsp={dsp}
|
|
||||||
channel={{ Input: InputChannel.InD }}
|
|
||||||
setLowPass={api.setLowPass}
|
|
||||||
setHighPass={api.setHighPass}
|
|
||||||
setPeq={api.setPeqBand}
|
|
||||||
setPeqBypass={api.setPEQBypass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'out1':
|
|
||||||
return (
|
|
||||||
<ChannelSection
|
|
||||||
dsp={dsp}
|
|
||||||
channel={{ Output: OutputChannel.Out1 }}
|
|
||||||
setLowPass={api.setLowPass}
|
|
||||||
setHighPass={api.setHighPass}
|
|
||||||
setPeq={api.setPeqBand}
|
|
||||||
setPeqBypass={api.setPEQBypass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
activeId={activeSidebar}
|
activeId={activeSidebar}
|
||||||
onSelect={setActiveSidebar}
|
onSelect={setActiveSidebar}
|
||||||
appName={dsp.name}
|
appName={dsp.name}
|
||||||
appOptions={presets}
|
appOptions={presets}
|
||||||
selectedAppId={presets[selectedPreset]?.id}
|
selectedAppId={presets[selectedPreset]?.id}
|
||||||
onAppChange={onAppChange}
|
onAppChange={onAppChange}
|
||||||
onAppRename={api.setCurrentPresetName}
|
onAppRename={api.setCurrentPresetName}
|
||||||
expanded={sidebarExpanded}
|
onItemRename={onItemRename}
|
||||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
expanded={sidebarExpanded}
|
||||||
/>
|
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
<LoadingOverlay
|
<LoadingOverlay
|
||||||
active={presetLoading}
|
active={presetLoading}
|
||||||
label="Recalling preset…"
|
label="Recalling preset…"
|
||||||
overlayClassName="dsp-loading-overlay"
|
overlayClassName="dsp-loading-overlay"
|
||||||
>
|
>
|
||||||
<div className="dsp-content">{renderTab()}</div>
|
<div className="dsp-content">{renderTab()}</div>
|
||||||
</LoadingOverlay>
|
</LoadingOverlay>
|
||||||
|
|
||||||
|
|
||||||
|
{dsp.passwordRequired && (
|
||||||
|
<PasswordDialog
|
||||||
|
name={dsp.name}
|
||||||
|
onSubmit={(pw: string) => api.connectWithPassword(pw)}
|
||||||
|
onCancel={() => api.cancelPassword()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import VerticalSlider from '../../../../components/VerticalSlider/VerticalSlider';
|
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 { DSP408 as DSP } from '../../../../types';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
@@ -58,23 +58,33 @@ function getGainCurvePoints(freqs: number[], gainDb: number): CurvePoint[] {
|
|||||||
|
|
||||||
function getPeqCrossoverPoints(
|
function getPeqCrossoverPoints(
|
||||||
freqs: number[],
|
freqs: number[],
|
||||||
peqChain: { bands: Record<string, unknown> },
|
peqChain: {
|
||||||
|
bypass: boolean;
|
||||||
|
bands: Record<PEQBand, PEQ>;
|
||||||
|
},
|
||||||
crossover: {
|
crossover: {
|
||||||
high_pass: { frequency: number; slope: unknown };
|
high_pass: { frequency: number; slope: CrossoverFilter };
|
||||||
low_pass: { frequency: number; slope: unknown };
|
low_pass: { frequency: number; slope: CrossoverFilter };
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return sumCurvePoints([
|
return sumCurvePoints([
|
||||||
getPeqCurvePoints({ freqs, bands: peqChain.bands as any }),
|
...(peqChain.bypass
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
getPeqCurvePoints({
|
||||||
|
freqs,
|
||||||
|
bands: peqChain.bands,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
getCrossoverCurvePoints({
|
getCrossoverCurvePoints({
|
||||||
freqs,
|
freqs,
|
||||||
highPass: {
|
highPass: {
|
||||||
frequency: crossover.high_pass.frequency,
|
frequency: crossover.high_pass.frequency,
|
||||||
filter_type: crossover.high_pass.slope as any,
|
filter_type: crossover.high_pass.slope,
|
||||||
},
|
},
|
||||||
lowPass: {
|
lowPass: {
|
||||||
frequency: crossover.low_pass.frequency,
|
frequency: crossover.low_pass.frequency,
|
||||||
filter_type: crossover.low_pass.slope as any,
|
filter_type: crossover.low_pass.slope,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -86,9 +96,19 @@ function getInputChannelCurves(
|
|||||||
liveGain: number | undefined
|
liveGain: number | undefined
|
||||||
): ChannelCurves {
|
): ChannelCurves {
|
||||||
const gain = liveGain ?? state.gain;
|
const gain = liveGain ?? state.gain;
|
||||||
|
|
||||||
const geqValues = GEQ_BANDS_LABEL.map(({ frequency }) => state.geq.gains[frequency] ?? 0);
|
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 peqCrossover = getPeqCrossoverPoints(freqs, state.peq_chain, state.crossover);
|
||||||
|
|
||||||
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
|
const total = sumCurvePoints([geq, peqCrossover, getGainCurvePoints(freqs, gain)]);
|
||||||
|
|
||||||
return { geq, peqCrossover, total };
|
return { geq, peqCrossover, total };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
GEQ_BANDS_LABEL,
|
GEQ_BANDS_LABEL,
|
||||||
GEQ_CURVE_Q,
|
GEQ_CURVE_Q,
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import './GeqSection.css';
|
import './GeqSection.css';
|
||||||
import { getGeqCurvePoints } from '../../utils/graph/geq';
|
import { getGeqCurvePoints } from '../../utils/graph/geq';
|
||||||
import { logSpace } from '../../utils/graph/curve';
|
import { logSpace } from '../../utils/graph/curve';
|
||||||
@@ -175,7 +175,7 @@ function GeqActions({
|
|||||||
<div className="geq-channel-selector">
|
<div className="geq-channel-selector">
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
className={bypass ? 'active-info' : ''}
|
className={bypass ? 'active-warning' : ''}
|
||||||
onClick={onBypass}
|
onClick={onBypass}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -201,7 +201,7 @@ function GeqActions({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */}
|
{resetting ? 'Resetting…' : 'Reset'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -219,6 +219,20 @@ function GeqSection({
|
|||||||
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
|
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
|
||||||
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
|
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
|
||||||
const [resetting, setResetting] = useState(false);
|
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) => {
|
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
|
||||||
setLiveGeq((prev) => ({
|
setLiveGeq((prev) => ({
|
||||||
@@ -245,6 +259,10 @@ function GeqSection({
|
|||||||
setGeqBypass(activeChannel, !activeGeq.bypass);
|
setGeqBypass(activeChannel, !activeGeq.bypass);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetClick = () => {
|
||||||
|
setIsResetConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Sequential per-band reset, since hardware has no bulk reset command.
|
// Sequential per-band reset, since hardware has no bulk reset command.
|
||||||
const handleReset = async (channel: InputChannel) => {
|
const handleReset = async (channel: InputChannel) => {
|
||||||
if (resetting) return;
|
if (resetting) return;
|
||||||
@@ -261,6 +279,11 @@ function GeqSection({
|
|||||||
setResetting(false);
|
setResetting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmReset = async () => {
|
||||||
|
setIsResetConfirmOpen(false);
|
||||||
|
await handleReset(activeChannel);
|
||||||
|
};
|
||||||
|
|
||||||
const activeInputState = dsp.state.current_config.input_states[activeChannel];
|
const activeInputState = dsp.state.current_config.input_states[activeChannel];
|
||||||
const activeGeq = activeInputState.geq;
|
const activeGeq = activeInputState.geq;
|
||||||
const activeLiveBands = liveGeq[activeChannel];
|
const activeLiveBands = liveGeq[activeChannel];
|
||||||
@@ -306,12 +329,33 @@ function GeqSection({
|
|||||||
bypass={activeGeq.bypass}
|
bypass={activeGeq.bypass}
|
||||||
resetting={resetting}
|
resetting={resetting}
|
||||||
onBypass={handleBypassClick}
|
onBypass={handleBypassClick}
|
||||||
onReset={() => handleReset(activeChannel)}
|
onReset={handleResetClick}
|
||||||
/>
|
/>
|
||||||
</ChannelGroup>
|
</ChannelGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Channel, InputChannel, OutputChannel } from '../../types';
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import Card from '../../../../components/Card/Card';
|
||||||
|
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||||
|
|
||||||
|
import './CopyChannelModal.css';
|
||||||
|
|
||||||
|
const INPUT_CHANNELS: readonly string[] = Object.values(InputChannel);
|
||||||
|
const OUTPUT_CHANNELS: readonly string[] = Object.values(OutputChannel);
|
||||||
|
const ALL_CHANNELS: readonly string[] = [...INPUT_CHANNELS, ...OUTPUT_CHANNELS];
|
||||||
|
|
||||||
|
const isInputChannel = (raw: string) => INPUT_CHANNELS.includes(raw);
|
||||||
|
|
||||||
|
/** Wraps a raw enum string back into the { Input } / { Output } shape. */
|
||||||
|
const wrapChannel = (raw: string): Channel =>
|
||||||
|
isInputChannel(raw) ? { Input: raw as InputChannel } : { Output: raw as OutputChannel };
|
||||||
|
|
||||||
|
/** The channels a given source can be copied onto: same type, minus itself. */
|
||||||
|
const destinationsFor = (source: string): string[] =>
|
||||||
|
(isInputChannel(source) ? INPUT_CHANNELS : OUTPUT_CHANNELS).filter(
|
||||||
|
(channel) => channel !== source
|
||||||
|
);
|
||||||
|
|
||||||
|
function CopyChannelModal({
|
||||||
|
onClose,
|
||||||
|
copyChannel,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const [source, setSource] = useState<string>(ALL_CHANNELS[0]);
|
||||||
|
const [destination, setDestination] = useState<string>(() => destinationsFor(ALL_CHANNELS[0])[0]);
|
||||||
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
|
||||||
|
const destinationOptions = useMemo(() => destinationsFor(source), [source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSourceChange = (nextSource: string) => {
|
||||||
|
setSource(nextSource);
|
||||||
|
|
||||||
|
// Picking a source of the other type (or picking the current
|
||||||
|
// destination as source) invalidates the destination.
|
||||||
|
const nextDestinations = destinationsFor(nextSource);
|
||||||
|
|
||||||
|
if (!nextDestinations.includes(destination)) {
|
||||||
|
setDestination(nextDestinations[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
setIsCopying(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyChannel(wrapChannel(source), wrapChannel(destination));
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsCopying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="copy-channel-overlay"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="copy-channel-modal">
|
||||||
|
<div className="copy-channel-modal__header">
|
||||||
|
<h2>Copy Channel</h2>
|
||||||
|
<p>
|
||||||
|
Copy every setting of one channel onto another. Channels can only be copied onto
|
||||||
|
channels of the same type.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="copy-channel-modal__transfer">
|
||||||
|
<div className="copy-channel-modal__field">
|
||||||
|
<label htmlFor="copy-channel-source">From</label>
|
||||||
|
|
||||||
|
<ChoiceSelect<string>
|
||||||
|
id="copy-channel-source"
|
||||||
|
value={source}
|
||||||
|
options={ALL_CHANNELS}
|
||||||
|
onChange={handleSourceChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="copy-channel-modal__arrow" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="copy-channel-modal__field">
|
||||||
|
<label htmlFor="copy-channel-destination">To</label>
|
||||||
|
|
||||||
|
<ChoiceSelect<string>
|
||||||
|
id="copy-channel-destination"
|
||||||
|
value={destination}
|
||||||
|
options={destinationOptions}
|
||||||
|
onChange={setDestination}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="copy-channel-modal__summary">
|
||||||
|
{destination} will take the settings of {source}.
|
||||||
|
<br />
|
||||||
|
This overwrites {destination}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="copy-channel-modal__actions">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isCopying}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleCopy} disabled={isCopying}>
|
||||||
|
{isCopying ? 'Copying...' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyChannelModal;
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
.link-channels-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: link-channels-fade-in var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes link-channels-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
animation: link-channels-scale-in var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes link-channels-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal__header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal__header p {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode toggle */
|
||||||
|
|
||||||
|
.link-channels-modal__mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
padding: var(--space-1);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal__mode-btn {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background var(--dur-base) var(--ease-standard),
|
||||||
|
color var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-channels-modal__mode-btn.is-active {
|
||||||
|
background: var(--accent-brand);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Triangular link matrix */
|
||||||
|
|
||||||
|
.link-matrix {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__corner {
|
||||||
|
width: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__col-label,
|
||||||
|
.link-matrix__row-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__row-label {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
min-width: 32px;
|
||||||
|
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--empty {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background var(--dur-base) var(--ease-standard),
|
||||||
|
border-color var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--toggle:hover {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--toggle.is-active {
|
||||||
|
background: var(--accent-brand);
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--toggle:disabled:hover {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A row whose channel is itself following another channel can't also
|
||||||
|
lead others - its other tiles are locked rather than just inactive. */
|
||||||
|
|
||||||
|
.link-matrix__cell--unavailable {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--border-hairline),
|
||||||
|
var(--border-hairline) 2px,
|
||||||
|
transparent 2px,
|
||||||
|
transparent 6px
|
||||||
|
);
|
||||||
|
border-color: var(--border-hairline);
|
||||||
|
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-matrix__cell--unavailable:hover {
|
||||||
|
border-color: var(--border-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer actions */
|
||||||
|
|
||||||
|
.link-channels-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.link-matrix__cell {
|
||||||
|
min-width: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { DSP408 } from '../../../../types';
|
||||||
|
import { Channel, InputChannel, OutputChannel } from '../../types';
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import Card from '../../../../components/Card/Card';
|
||||||
|
|
||||||
|
import './LinkChannelsModal.css';
|
||||||
|
|
||||||
|
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
|
||||||
|
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
|
||||||
|
|
||||||
|
type LinkMode = 'input' | 'output';
|
||||||
|
|
||||||
|
type LinkGroup = {
|
||||||
|
source: Channel;
|
||||||
|
destinations: Channel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps every channel (by its raw enum string) to the channel it currently
|
||||||
|
* follows. A channel that follows itself is unlinked. Several channels can
|
||||||
|
* follow the same leader (many-to-one), but a channel that is itself
|
||||||
|
* following someone can't also be followed by others - no chains.
|
||||||
|
*/
|
||||||
|
type LeaderMap = Record<string, string>;
|
||||||
|
|
||||||
|
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
|
||||||
|
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
|
||||||
|
* The device state has been observed to store these as plain strings
|
||||||
|
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
|
||||||
|
* shape used elsewhere, so this accepts either form defensively.
|
||||||
|
*/
|
||||||
|
function channelRawId(channel: Channel | string): string {
|
||||||
|
if (typeof channel === 'string') {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Input' in channel ? channel.Input : channel.Output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks any chains that might be present in a leader map (a channel that
|
||||||
|
* both follows someone and is followed by others) by releasing whoever
|
||||||
|
* follows a follower back to themselves. Keeps the invariant - a channel
|
||||||
|
* can lead or follow, never both - true regardless of what the device
|
||||||
|
* reported.
|
||||||
|
*/
|
||||||
|
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
|
||||||
|
const next = { ...leaderOf };
|
||||||
|
|
||||||
|
Object.keys(next).forEach((channel) => {
|
||||||
|
const leader = next[channel];
|
||||||
|
|
||||||
|
if (leader !== channel && next[leader] !== leader) {
|
||||||
|
// `channel` follows a leader that itself follows someone else -
|
||||||
|
// release `channel` back to itself to break the chain.
|
||||||
|
next[channel] = channel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
|
||||||
|
* and turns it into a leader map, so the modal opens already reflecting the
|
||||||
|
* device. Every channel defaults to following itself (unlinked); channels
|
||||||
|
* reported as linked follow whichever channel the device reported first.
|
||||||
|
*/
|
||||||
|
function extractLeaderMap<T extends string>(
|
||||||
|
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
|
||||||
|
orderedChannels: readonly T[]
|
||||||
|
): LeaderMap {
|
||||||
|
const leaderOf: LeaderMap = {};
|
||||||
|
|
||||||
|
orderedChannels.forEach((channel) => {
|
||||||
|
leaderOf[channel] = channel;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!states) {
|
||||||
|
return leaderOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedChannels.forEach((channel) => {
|
||||||
|
const state = states[channel];
|
||||||
|
|
||||||
|
if (!state || state.linked_channels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderId = channelRawId(state.linked_channels[0]);
|
||||||
|
|
||||||
|
if (leaderId !== channel) {
|
||||||
|
leaderOf[channel] = leaderId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeLeaderMap(leaderOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a leader map into one linkChannels call per leader that has
|
||||||
|
* followers (source = leader, destinations = its followers), plus one call
|
||||||
|
* per still-unlinked, follower-less channel with no destinations. Channels
|
||||||
|
* that are themselves following someone don't get their own call - they're
|
||||||
|
* already covered by their leader's call.
|
||||||
|
*/
|
||||||
|
function buildLinkCalls(
|
||||||
|
leaderOf: LeaderMap,
|
||||||
|
orderedChannels: readonly string[],
|
||||||
|
wrap: (raw: string) => Channel
|
||||||
|
): LinkGroup[] {
|
||||||
|
const followersByLeader = new Map<string, string[]>();
|
||||||
|
|
||||||
|
orderedChannels.forEach((channel) => {
|
||||||
|
const leader = leaderOf[channel];
|
||||||
|
|
||||||
|
if (leader === channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = followersByLeader.get(leader) ?? [];
|
||||||
|
existing.push(channel);
|
||||||
|
followersByLeader.set(leader, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: LinkGroup[] = [];
|
||||||
|
|
||||||
|
orderedChannels.forEach((channel) => {
|
||||||
|
const followers = followersByLeader.get(channel);
|
||||||
|
|
||||||
|
if (followers) {
|
||||||
|
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderOf[channel] === channel) {
|
||||||
|
calls.push({ source: wrap(channel), destinations: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkChannelsModal({
|
||||||
|
dsp,
|
||||||
|
onClose,
|
||||||
|
linkChannels,
|
||||||
|
}: {
|
||||||
|
dsp: DSP408;
|
||||||
|
onClose: () => void;
|
||||||
|
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<LinkMode>('input');
|
||||||
|
|
||||||
|
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
|
||||||
|
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
|
||||||
|
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
|
||||||
|
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
|
||||||
|
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const followChannel = (follower: string, leader: string) => {
|
||||||
|
setLeaderOf((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
|
||||||
|
// `follower` can no longer lead anyone once it follows `leader` -
|
||||||
|
// release whoever currently follows `follower` back to themselves.
|
||||||
|
Object.keys(next).forEach((channel) => {
|
||||||
|
if (next[channel] === follower) {
|
||||||
|
next[channel] = channel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next[follower] = leader;
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlinkChannel = (channel: string) => {
|
||||||
|
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellClick = (row: string, col: string) => {
|
||||||
|
const isSelf = row === col;
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
if (leaderOf[row] !== row) {
|
||||||
|
unlinkChannel(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaderOf[row] !== row || leaderOf[col] === row) {
|
||||||
|
// Row is locked (it's itself following someone) or this pair is
|
||||||
|
// already the current state - nothing to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
followChannel(col, row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidate = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = [
|
||||||
|
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
|
||||||
|
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await linkChannels(group.source, group.destinations);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="link-channels-overlay"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card className="link-channels-modal">
|
||||||
|
<div className="link-channels-modal__header">
|
||||||
|
<h2>Link Channels</h2>
|
||||||
|
<p>
|
||||||
|
Link one or more channels to a single channel. A channel not linked to another is linked
|
||||||
|
to itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'input'}
|
||||||
|
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setMode('input')}
|
||||||
|
>
|
||||||
|
Input
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'output'}
|
||||||
|
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setMode('output')}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="link-matrix"
|
||||||
|
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="link-matrix__corner" />
|
||||||
|
|
||||||
|
{channels.map((col) => (
|
||||||
|
<div className="link-matrix__col-label" key={`col-${col}`}>
|
||||||
|
{col}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{channels.map((row, rowIndex) => {
|
||||||
|
const isRowLocked = leaderOf[row] !== row;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="link-matrix__row" key={`row-${row}`}>
|
||||||
|
<div className="link-matrix__row-label">{row}</div>
|
||||||
|
|
||||||
|
{channels.map((col, colIndex) => {
|
||||||
|
if (colIndex < rowIndex) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="link-matrix__cell link-matrix__cell--empty"
|
||||||
|
key={`cell-${row}-${col}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = row === col;
|
||||||
|
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
|
||||||
|
const isUnavailable = !isSelf && !isActive && isRowLocked;
|
||||||
|
|
||||||
|
const label = isSelf
|
||||||
|
? isActive
|
||||||
|
? `${row} not linked`
|
||||||
|
: `Unlink ${row}`
|
||||||
|
: isUnavailable
|
||||||
|
? `${row} is already linked to another channel`
|
||||||
|
: isActive
|
||||||
|
? `${col} linked to ${row}`
|
||||||
|
: `Link ${col} to ${row}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={`cell-${row}-${col}`}
|
||||||
|
className={[
|
||||||
|
'link-matrix__cell',
|
||||||
|
'link-matrix__cell--toggle',
|
||||||
|
isUnavailable && 'link-matrix__cell--unavailable',
|
||||||
|
isActive && 'is-active',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
disabled={isActive || isUnavailable}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => handleCellClick(row, col)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="link-channels-modal__actions">
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Linking...' : 'Validate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkChannelsModal;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
.miscellaneous-section {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
|
||||||
|
.miscellaneous-panel__section {
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
|
||||||
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__section:first-of-type {
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__section-header p {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
|
||||||
|
.miscellaneous-panel__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field input {
|
||||||
|
height: var(--card-control-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
transition: border-color var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field input.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field input:focus {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stepper */
|
||||||
|
|
||||||
|
.miscellaneous-panel__choice-select {
|
||||||
|
height: var(--card-control-height);
|
||||||
|
min-height: var(--card-control-height);
|
||||||
|
max-height: var(--card-control-height);
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
.miscellaneous-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger */
|
||||||
|
|
||||||
|
.miscellaneous-panel__section--danger {
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__section--danger .miscellaneous-panel__section-header p {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Input */
|
||||||
|
|
||||||
|
.miscellaneous-panel__password-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__password-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 3%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__password-toggle:hover {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__password-toggle svg {
|
||||||
|
width: auto;
|
||||||
|
height: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.miscellaneous-panel__row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miscellaneous-panel input,
|
||||||
|
.miscellaneous-panel select {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--space-5);
|
||||||
|
margin: var(--space-4);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal h3 {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DSP408 } from '../../../../types';
|
||||||
|
import { Channel, InputSource, InputType, DiscreteFrequency } from '../../types';
|
||||||
|
import { SidebarId } from '../../constants';
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import Card from '../../../../components/Card/Card';
|
||||||
|
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||||
|
import LinkChannelsModal from './LinkChannelsModal';
|
||||||
|
import CopyChannelModal from './CopyChannelModal';
|
||||||
|
|
||||||
|
import './MiscellaneousSection.css';
|
||||||
|
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
|
||||||
|
|
||||||
|
const INPUT_TYPES: readonly InputType[] = Object.values(InputType);
|
||||||
|
const DISCRETE_FREQUENCIES: readonly DiscreteFrequency[] = Object.values(DiscreteFrequency);
|
||||||
|
|
||||||
|
function MiscellaneousPanel({
|
||||||
|
dsp,
|
||||||
|
factoryReset,
|
||||||
|
copyChannel,
|
||||||
|
setInputSource,
|
||||||
|
linkChannels,
|
||||||
|
lockDevice,
|
||||||
|
setActiveSidebar,
|
||||||
|
}: {
|
||||||
|
dsp: DSP408;
|
||||||
|
factoryReset: (force: boolean) => Promise<boolean>;
|
||||||
|
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
||||||
|
setInputSource: (inputSource: InputSource) => Promise<boolean>;
|
||||||
|
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||||
|
lockDevice: (password: string) => Promise<boolean>;
|
||||||
|
setActiveSidebar: (id: SidebarId) => void;
|
||||||
|
}) {
|
||||||
|
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
|
||||||
|
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||||
|
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
|
||||||
|
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [inputSource, setInputSourceState] = useState<InputSource>({
|
||||||
|
type: InputType.Analog,
|
||||||
|
frequency: DiscreteFrequency.F20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleInputTypeChange = async (type: InputType) => {
|
||||||
|
const nextSource: InputSource = {
|
||||||
|
type,
|
||||||
|
frequency: type === InputType.SineWave ? inputSource.frequency : DiscreteFrequency.F20,
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputSourceState(nextSource);
|
||||||
|
await setInputSource(nextSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFrequencyChange = async (frequency: DiscreteFrequency) => {
|
||||||
|
const nextSource: InputSource = {
|
||||||
|
...inputSource,
|
||||||
|
frequency,
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputSourceState(nextSource);
|
||||||
|
await setInputSource(nextSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFactoryReset = () => {
|
||||||
|
setIsResetConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmFactoryReset = async () => {
|
||||||
|
setIsResetConfirmOpen(false);
|
||||||
|
setIsFactoryResetting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await factoryReset(true);
|
||||||
|
} finally {
|
||||||
|
setIsFactoryResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLock = async () => {
|
||||||
|
if (!password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await lockDevice(password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setPassword('');
|
||||||
|
setActiveSidebar('overview');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="miscellaneous-section">
|
||||||
|
<Card className="miscellaneous-panel">
|
||||||
|
{/* Input Source */}
|
||||||
|
<section className="miscellaneous-panel__section">
|
||||||
|
<div className="miscellaneous-panel__section-header">
|
||||||
|
<h2>Input Source</h2>
|
||||||
|
<p>Select the signal source used by the DSP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__row">
|
||||||
|
<div className="miscellaneous-panel__field">
|
||||||
|
<label htmlFor="misc-input-type">Source</label>
|
||||||
|
|
||||||
|
<ChoiceSelect<InputType>
|
||||||
|
id="misc-input-type"
|
||||||
|
className='miscellaneous-panel__choice-select'
|
||||||
|
value={inputSource.type}
|
||||||
|
options={INPUT_TYPES}
|
||||||
|
onChange={handleInputTypeChange}
|
||||||
|
style={{ color: 'var(--font-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inputSource.type === InputType.SineWave && (
|
||||||
|
<div className="miscellaneous-panel__field">
|
||||||
|
<label htmlFor="misc-frequency">Frequency</label>
|
||||||
|
|
||||||
|
<ChoiceSelect<DiscreteFrequency>
|
||||||
|
id="misc-frequency"
|
||||||
|
className='miscellaneous-panel__choice-select'
|
||||||
|
value={inputSource.frequency}
|
||||||
|
options={DISCRETE_FREQUENCIES}
|
||||||
|
formatLabel={(frequency) => {
|
||||||
|
if (frequency === DiscreteFrequency.F31_5) {
|
||||||
|
return '31.5 Hz';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${frequency.slice(1)} Hz`;
|
||||||
|
}}
|
||||||
|
onChange={handleFrequencyChange}
|
||||||
|
style={{ color: 'var(--font-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Channel Tools */}
|
||||||
|
<section className="miscellaneous-panel__section">
|
||||||
|
<div className="miscellaneous-panel__section-header">
|
||||||
|
<h2>Channel Tools</h2>
|
||||||
|
<p>Copy settings between channels or link multiple channels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__actions">
|
||||||
|
<Button variant="outline" onClick={() => setIsCopyModalOpen(true)}>
|
||||||
|
Copy Channel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => setIsLinkModalOpen(true)}>
|
||||||
|
Link Channels
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Security */}
|
||||||
|
<section className="miscellaneous-panel__section">
|
||||||
|
<div className="miscellaneous-panel__section-header">
|
||||||
|
<h2>Device Security</h2>
|
||||||
|
<p>Lock access to the DSP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__field">
|
||||||
|
<label htmlFor="misc-password">Password</label>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__password-wrap">
|
||||||
|
<input
|
||||||
|
id="misc-password"
|
||||||
|
type={isPasswordVisible ? 'text' : 'password'}
|
||||||
|
maxLength={4}
|
||||||
|
value={password}
|
||||||
|
placeholder="Enter password"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
if (/^[\x20-\x7E]*$/.test(value)) {
|
||||||
|
setPassword(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="miscellaneous-panel__password-toggle"
|
||||||
|
onClick={() => setIsPasswordVisible((v) => !v)}
|
||||||
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={isPasswordVisible}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__actions">
|
||||||
|
<Button variant="outline" onClick={handleLock} disabled={password.length !== 4}>
|
||||||
|
Lock Device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Factory Reset */}
|
||||||
|
<section className="miscellaneous-panel__section miscellaneous-panel__section--danger">
|
||||||
|
<div className="miscellaneous-panel__section-header">
|
||||||
|
<h2>Factory Reset</h2>
|
||||||
|
<p>Restore the DSP to its factory configuration. This cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miscellaneous-panel__actions">
|
||||||
|
<Button variant="outline" onClick={handleFactoryReset} disabled={isFactoryResetting}>
|
||||||
|
{isFactoryResetting ? 'Resetting...' : 'Factory Reset'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLinkModalOpen && (
|
||||||
|
<LinkChannelsModal
|
||||||
|
dsp={dsp}
|
||||||
|
onClose={() => setIsLinkModalOpen(false)}
|
||||||
|
linkChannels={linkChannels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCopyModalOpen && (
|
||||||
|
<CopyChannelModal onClose={() => setIsCopyModalOpen(false)} copyChannel={copyChannel} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isResetConfirmOpen && (
|
||||||
|
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
|
||||||
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Factory Reset DSP?</h3>
|
||||||
|
<p>
|
||||||
|
This will restore the DSP to its factory configuration. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="confirm-modal__actions">
|
||||||
|
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={confirmFactoryReset}>
|
||||||
|
Factory Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MiscellaneousPanel;
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
.password-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: var(--space-4);
|
||||||
|
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog {
|
||||||
|
width: 340px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
animation: password-dialog-enter 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
|
||||||
|
.password-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__header-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__type {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
|
||||||
|
.password-dialog__content {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__field label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password */
|
||||||
|
|
||||||
|
.password-dialog__password-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--card-control-height);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
|
||||||
|
padding-right: calc(var(--space-3) + 28px);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-base) var(--ease-standard),
|
||||||
|
box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input:focus {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px
|
||||||
|
color-mix(in srgb, var(--accent-brand) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: var(--space-2);
|
||||||
|
|
||||||
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle:hover {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-toggle svg {
|
||||||
|
width: auto;
|
||||||
|
height: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
.password-dialog__actions {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
|
||||||
|
@keyframes password-dialog-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::-ms-reveal,
|
||||||
|
.password-dialog__password-wrap input::-ms-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog__password-wrap input::-webkit-credentials-auto-fill-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Button from '../../../../components/Button/Button';
|
||||||
|
import Card from '../../../../components/Card/Card';
|
||||||
|
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
|
||||||
|
|
||||||
|
import './PasswordDialog.css';
|
||||||
|
|
||||||
|
export function PasswordDialog({
|
||||||
|
name,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onSubmit: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (password.length === 4) {
|
||||||
|
onSubmit(password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (value: string) => {
|
||||||
|
if (/^[\x20-\x7E]*$/.test(value) && value.length <= 4) {
|
||||||
|
setPassword(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="password-dialog-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="password-dialog-title"
|
||||||
|
onMouseDown={handleOverlayClick}
|
||||||
|
>
|
||||||
|
<div className="password-dialog-wrapper">
|
||||||
|
<Card className="password-dialog">
|
||||||
|
<div className="password-dialog__header">
|
||||||
|
<div className="password-dialog__header-text">
|
||||||
|
<h1 id="password-dialog-title" className="password-dialog__title">
|
||||||
|
Device Locked
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="password-dialog__type" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="password-dialog__content">
|
||||||
|
<div className="password-dialog__field">
|
||||||
|
<label htmlFor="device-password">Password</label>
|
||||||
|
|
||||||
|
<div className="password-dialog__password-wrap">
|
||||||
|
<input
|
||||||
|
id="device-password"
|
||||||
|
type={isPasswordVisible ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
maxLength={4}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && password.length === 4) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-dialog__password-toggle"
|
||||||
|
onClick={() => setIsPasswordVisible((visible) => !visible)}
|
||||||
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={isPasswordVisible}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="password-dialog__actions">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={password.length !== 4}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -225,6 +225,20 @@ export const PEQ_FILTERS_LABEL: { value: PEQFilter; label: string }[] = [
|
|||||||
{ value: PEQFilter.AllPass2, label: 'All Pass 2' },
|
{ value: PEQFilter.AllPass2, label: 'All Pass 2' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Filter types that ignore Q (first-order filters). */
|
||||||
|
export const PEQ_Q_UNUSED = new Set<PEQFilter>([
|
||||||
|
PEQFilter.Lp6Db,
|
||||||
|
PEQFilter.Hp6Db,
|
||||||
|
PEQFilter.AllPass1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Filter types that ignore gain (everything except Peak and the shelves). */
|
||||||
|
export const PEQ_GAIN_USED = new Set<PEQFilter>([
|
||||||
|
PEQFilter.Peak,
|
||||||
|
PEQFilter.LowShelf,
|
||||||
|
PEQFilter.HighShelf,
|
||||||
|
]);
|
||||||
|
|
||||||
export const FREQUENCY_MIN = 19.7;
|
export const FREQUENCY_MIN = 19.7;
|
||||||
export const FREQUENCY_MAX = 20160;
|
export const FREQUENCY_MAX = 20160;
|
||||||
export const FREQUENCY_QUANTIZATION = 300;
|
export const FREQUENCY_QUANTIZATION = 300;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PEQBand,
|
PEQBand,
|
||||||
InputSource,
|
InputSource,
|
||||||
DSP408Snapshot,
|
DSP408Snapshot,
|
||||||
|
DSPMode,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
|
import { SetDsps, NotifyFn, updateDSP, setPollingPaused } from '../../dsp/dspShared';
|
||||||
import { registerPollStopper } from '../../dsp/pollRegistry';
|
import { registerPollStopper } from '../../dsp/pollRegistry';
|
||||||
@@ -25,7 +26,7 @@ export type DSP408Api = ReturnType<typeof forDSP408>;
|
|||||||
const selectedIds = new Set<number>();
|
const selectedIds = new Set<number>();
|
||||||
const pollIntervals = new Map<number, ReturnType<typeof setInterval>>();
|
const pollIntervals = new Map<number, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
const ACTIVE_POLL_INTERVAL_MS = 1000;
|
const ACTIVE_POLL_INTERVAL_MS = 100;
|
||||||
const BACKGROUND_POLL_INTERVAL_MS = 5000;
|
const BACKGROUND_POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
function pollIntervalFor(id: number): number {
|
function pollIntervalFor(id: number): number {
|
||||||
@@ -117,8 +118,8 @@ export async function create(params: {
|
|||||||
ip: params.ip,
|
ip: params.ip,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
deviceId: params.deviceId,
|
deviceId: params.deviceId,
|
||||||
mode: snapshot.mode,
|
mode: params.demo ? DSPMode.Demo: DSPMode.Normal,
|
||||||
status: 'disconnected',
|
isConnecting: false,
|
||||||
pollingPaused: true,
|
pollingPaused: true,
|
||||||
state: snapshot.state,
|
state: snapshot.state,
|
||||||
input_meters: snapshot.input_meters,
|
input_meters: snapshot.input_meters,
|
||||||
@@ -127,16 +128,61 @@ export async function create(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
||||||
async function updateDSPState(): Promise<void> {
|
async function updateDSPState(): Promise<DSP408['state']> {
|
||||||
try {
|
try {
|
||||||
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
|
const state = await invoke<DSP408['state']>('get_dsp_state', { id: dsp.id });
|
||||||
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, state }) as DSP408);
|
||||||
|
|
||||||
|
return state;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
|
notify(`Failed to fetch state: ${String(err)}`, 'error', dsp.name);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLocked = (s: DSP408['state']) => s.flags.is_locked;
|
||||||
|
|
||||||
|
async function abortLocked() {
|
||||||
|
await invoke('disconnect_dsp408', { id: dsp.id }).catch(() => {});
|
||||||
|
|
||||||
|
updateDSPState();
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false, passwordRequired: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectInternal(password?: string) {
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: true, passwordRequired: false }));
|
||||||
|
try {
|
||||||
|
await invoke('connect_dsp408', { id: dsp.id });
|
||||||
|
|
||||||
|
let state = await updateDSPState();
|
||||||
|
|
||||||
|
if (isLocked(state)) {
|
||||||
|
if (password === undefined) {
|
||||||
|
await abortLocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('unlock_device', { id: dsp.id, password });
|
||||||
|
|
||||||
|
state = await updateDSPState();
|
||||||
|
|
||||||
|
if (isLocked(state)) {
|
||||||
|
await abortLocked();
|
||||||
|
notify('Wrong password', 'error', dsp.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false, pollingPaused: false }));
|
||||||
|
notify('Connected', 'success', dsp.name);
|
||||||
|
startPolling(dsp, setDsps);
|
||||||
|
} catch (err) {
|
||||||
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, isConnecting: false }));
|
||||||
|
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'DSP408' as const,
|
type: 'DSP408' as const,
|
||||||
|
|
||||||
@@ -155,22 +201,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
updateDSPState,
|
updateDSPState,
|
||||||
|
|
||||||
async connect() {
|
connect: () => connectInternal(),
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connecting' }));
|
connectWithPassword: (password: string) => connectInternal(password),
|
||||||
try {
|
cancelPassword() {
|
||||||
await invoke('connect_dsp408', { id: dsp.id });
|
updateDSP(setDsps, dsp.id, (d) => ({ ...d, passwordRequired: false }));
|
||||||
|
|
||||||
await updateDSPState();
|
|
||||||
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'connected', pollingPaused: false }));
|
|
||||||
|
|
||||||
notify('Connected', 'success', dsp.name);
|
|
||||||
|
|
||||||
startPolling(dsp, setDsps);
|
|
||||||
} catch (err) {
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({ ...d, status: 'disconnected' }));
|
|
||||||
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
@@ -181,9 +215,10 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
await invoke('disconnect_dsp408', { id: dsp.id });
|
await invoke('disconnect_dsp408', { id: dsp.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateDSPState();
|
||||||
|
|
||||||
updateDSP(setDsps, dsp.id, (d) => ({
|
updateDSP(setDsps, dsp.id, (d) => ({
|
||||||
...d,
|
...d,
|
||||||
status: 'disconnected',
|
|
||||||
pollingPaused: true,
|
pollingPaused: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -720,6 +755,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify(`Preset stored in ${dsp.state.presets.names[presetIndex]}`, 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Store configuration failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Store configuration failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -746,6 +783,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Factory reset completed', 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Factory reset failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Factory reset failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -769,6 +808,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Channels copied successfully', 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Channel copy failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Channel copy failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -791,6 +832,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Input source updated', 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Input source update failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Input source update failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -814,6 +857,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Channels linked successfully', 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Channel linking failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Channel linking failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -837,6 +882,8 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
|
|
||||||
await updateDSPState();
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Channel name updated', 'success', dsp.name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify(`Channel name update failed: ${String(err)}`, 'error', dsp.name);
|
notify(`Channel name update failed: ${String(err)}`, 'error', dsp.name);
|
||||||
@@ -844,5 +891,53 @@ export function forDSP408(dsp: DSP408, setDsps: SetDsps, notify: NotifyFn) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async unlockDevice(password: string): Promise<boolean> {
|
||||||
|
return withPollingPaused(dsp, setDsps, async () => {
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('unlock_device', {
|
||||||
|
id: dsp.id,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Device unlocked', 'success', dsp.name);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Unlock failed: ${String(err)}`, 'error', dsp.name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async lockDevice(password: string): Promise<boolean> {
|
||||||
|
return withPollingPaused(dsp, setDsps, async () => {
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('lock_device', {
|
||||||
|
id: dsp.id,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDSPState();
|
||||||
|
|
||||||
|
notify('Device locked', 'success', dsp.name);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Lock failed: ${String(err)}`, 'error', dsp.name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,9 +183,16 @@ export type DeviceFlags = {
|
|||||||
is_locked: boolean;
|
is_locked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum InputType {
|
||||||
|
Analog = 'Analog',
|
||||||
|
PinkNoise = 'PinkNoise',
|
||||||
|
WhiteNoise = 'WhiteNoise',
|
||||||
|
SineWave = 'SineWave',
|
||||||
|
}
|
||||||
|
|
||||||
export type InputSource = {
|
export type InputSource = {
|
||||||
type: string;
|
type: InputType;
|
||||||
frequency: string;
|
frequency: DiscreteFrequency;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InputChannelState = {
|
export type InputChannelState = {
|
||||||
@@ -236,15 +243,23 @@ export type DSPState = {
|
|||||||
flags: DeviceFlags;
|
flags: DeviceFlags;
|
||||||
presets: PresetBank;
|
presets: PresetBank;
|
||||||
current_config: DSPConfigState;
|
current_config: DSPConfigState;
|
||||||
|
connection: ConnectionStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DSPMode = 'demo' | 'disconnected' | 'connected';
|
export enum ConnectionStatus {
|
||||||
|
Connected = 'Connected',
|
||||||
|
Disconnected = 'Disconnected',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DSPMode {
|
||||||
|
Demo = 'Demo',
|
||||||
|
Normal = 'Normal',
|
||||||
|
}
|
||||||
|
|
||||||
export type DSP408Snapshot = {
|
export type DSP408Snapshot = {
|
||||||
state: DSPState;
|
state: DSPState;
|
||||||
input_meters: DSP408InputMeters;
|
input_meters: DSP408InputMeters;
|
||||||
output_meters: DSP408OutputMeters;
|
output_meters: DSP408OutputMeters;
|
||||||
mode: DSPMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Meter {
|
export interface Meter {
|
||||||
|
|||||||
@@ -139,16 +139,14 @@ function peqBandResponseDb(
|
|||||||
/** Sum of all bands' contributions at a given frequency. */
|
/** Sum of all bands' contributions at a given frequency. */
|
||||||
function peqTotalCurveDb(
|
function peqTotalCurveDb(
|
||||||
freq: number,
|
freq: number,
|
||||||
bands: Record<PEQBand, PEQ>,
|
bands: Partial<Record<PEQBand, PEQ>>,
|
||||||
sampleRate: number = DEFAULT_SAMPLE_RATE
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
): number {
|
): number {
|
||||||
let sum = 0;
|
return Object.values(bands).reduce(
|
||||||
|
(sum, band) =>
|
||||||
for (const band of Object.values(PEQBand)) {
|
band ? sum + peqBandResponseDb(freq, band, sampleRate) : sum,
|
||||||
sum += peqBandResponseDb(freq, bands[band], sampleRate);
|
0
|
||||||
}
|
);
|
||||||
|
|
||||||
return sum;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,16 +305,14 @@ function peqBandPhaseDeg(
|
|||||||
|
|
||||||
function peqTotalPhaseDeg(
|
function peqTotalPhaseDeg(
|
||||||
freq: number,
|
freq: number,
|
||||||
bands: Record<PEQBand, PEQ>,
|
bands: Partial<Record<PEQBand, PEQ>>,
|
||||||
sampleRate: number = DEFAULT_SAMPLE_RATE
|
sampleRate: number = DEFAULT_SAMPLE_RATE
|
||||||
): number {
|
): number {
|
||||||
let sum = 0;
|
return Object.values(bands).reduce(
|
||||||
|
(sum, band) =>
|
||||||
for (const band of Object.values(PEQBand)) {
|
band ? sum + peqBandPhaseDeg(freq, band, sampleRate) : sum,
|
||||||
sum += peqBandPhaseDeg(freq, bands[band], sampleRate);
|
0
|
||||||
}
|
);
|
||||||
|
|
||||||
return sum;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPeqPhasePoints({
|
export function getPeqPhasePoints({
|
||||||
|
|||||||
@@ -155,6 +155,11 @@
|
|||||||
height: var(--card-control-height);
|
height: var(--card-control-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-dsp-form__selector {
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.add-dsp-form__field .choice-stepper__input {
|
.add-dsp-form__field .choice-stepper__input {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function AddDSPForm({
|
|||||||
<div className="add-dsp-form__field">
|
<div className="add-dsp-form__field">
|
||||||
<label htmlFor="f-type">Model</label>
|
<label htmlFor="f-type">Model</label>
|
||||||
<ChoiceSelect
|
<ChoiceSelect
|
||||||
className=""
|
className="add-dsp-form__selector"
|
||||||
id="f-type"
|
id="f-type"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
options={DSP_TYPES}
|
options={DSP_TYPES}
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import {
|
|||||||
DSPMode,
|
DSPMode,
|
||||||
DSP408OutputMeters,
|
DSP408OutputMeters,
|
||||||
DSP408InputMeters,
|
DSP408InputMeters,
|
||||||
|
ConnectionStatus,
|
||||||
} from './features/dsp408/types';
|
} from './features/dsp408/types';
|
||||||
|
|
||||||
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
|
|
||||||
|
|
||||||
export type NotificationType = 'success' | 'error' | 'warning';
|
export type NotificationType = 'success' | 'error' | 'warning';
|
||||||
export type Notification = {
|
export type Notification = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,11 +37,16 @@ interface DSPBase {
|
|||||||
deviceId: number;
|
deviceId: number;
|
||||||
mode: DSPMode;
|
mode: DSPMode;
|
||||||
|
|
||||||
status: DSPStatus;
|
passwordRequired?: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
pollingPaused: boolean;
|
pollingPaused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isConnected(dsp: DSP): boolean {
|
||||||
|
return dsp.type === 'DSP408'
|
||||||
|
&& dsp.state.connection === ConnectionStatus.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
export type DSP408 = DSPBase & {
|
export type DSP408 = DSPBase & {
|
||||||
type: 'DSP408';
|
type: 'DSP408';
|
||||||
state: DSP408State;
|
state: DSP408State;
|
||||||
@@ -52,8 +56,6 @@ export type DSP408 = DSPBase & {
|
|||||||
|
|
||||||
export type DSP204 = DSPBase & {
|
export type DSP204 = DSPBase & {
|
||||||
type: 'DSP204';
|
type: 'DSP204';
|
||||||
state: null;
|
|
||||||
meters: null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DSP = DSP408 | DSP204;
|
export type DSP = DSP408 | DSP204;
|
||||||
|
|||||||