Compare commits

...
82 Commits
Author SHA1 Message Date
LucasDLTG e151ab4f26 fix: bugs using real hardware 2026-10-04 21:01:09 +02:00
LucasDLTG e28f61447f fix: q value 2026-10-02 22:48:47 +02:00
LucasDLTG 388552f1c4 fix: matrix gain 2026-10-02 20:38:23 +02:00
LucasDLTG e68e73d3d5 fix: render error on input 2026-09-30 21:14:08 +02:00
Lucas a5669805c8 fix: make led focus the tab 2026-09-30 16:55:47 +02:00
Lucas cef7336517 feat: remember panel per dsp 2026-09-30 16:53:44 +02:00
Lucas 50a3c1a1e5 fix: handle abort password 2026-09-30 16:35:15 +02:00
Lucas 684f5ce866 fix: too wide misc panel 2026-09-30 16:08:42 +02:00
Lucas 80da40f678 fix: disconnect button 2026-09-30 12:42:14 +02:00
LucasDLTG 3cca2f41f4 fix: connection led 2026-09-29 20:56:34 +02:00
Lucas 6511d9d007 unstable 2026-09-29 16:59:54 +02:00
Lucas d5a2afdea5 feat: add locking styles 2026-09-28 16:41:16 +02:00
Lucas 24830172ae feat: add rename channel 2026-09-25 15:10:19 +02:00
LucasDLTG fd7dd89f55 feat: match bypass button color 2026-09-24 18:13:57 +02:00
LucasDLTG dac6c729cd feat: remove outdated commentaries 2026-09-23 21:44:26 +02:00
LucasDLTG d48155683b feat: disable inputs if peq dont use them 2026-09-23 20:03:39 +02:00
LucasDLTG e1d6398d57 feat: dont draw geq/peq curves if bypass 2026-09-22 21:05:59 +02:00
LucasDLTG 4aae47cd27 merge 2026-09-22 19:12:40 +02:00
LucasDLTG 61f821c3e9 feat: add confirmation windows for reset 2026-09-22 19:09:38 +02:00
Lucas b74857d7f6 feat: disable click when exiting select list 2026-09-21 15:58:42 +02:00
Lucas d3a783f77f feat: custom item list in selector 2026-09-21 15:25:21 +02:00
Lucas f965779373 feat: add notification for all dsp query 2026-09-21 14:16:04 +02:00
Lucas b0d4e372cb feat: update logo + app metadata 2026-09-21 10:57:14 +02:00
LucasDLTG e893cab082 fix: unused import 2026-09-20 21:41:09 +02:00
LucasDLTG 10b890b5ae feat: implement copy and link 2026-09-20 21:40:25 +02:00
LucasDLTG e05d589311 fix: command in lib.rs 2026-09-13 22:39:01 +02:00
LucasDLTG 00b6721c42 feat: add MiscellaneousPanel - need to implement UI 2026-09-13 19:09:47 +02:00
LucasDLTG 4f2bce1f5f fix: preset renaming 2026-09-13 17:13:03 +02:00
LucasDLTG 8c0b272ab9 refactor: reorder tauri commands and highlight missing 2026-09-13 16:24:08 +02:00
LucasDLTG 772a331f04 fix: hanging notifications 2026-09-13 15:55:11 +02:00
LucasDLTG 1643a887af feat: add peq output pages 2026-09-13 15:48:26 +02:00
LucasDLTG 8e9a59d026 feat: finalize input section 2026-09-13 14:25:00 +02:00
LucasDLTG 1792910ca1 fix: remove useless vars 2026-09-13 10:51:04 +02:00
LucasDLTG faa019d52b fix: matrix choice selector 2026-09-13 10:50:43 +02:00
LucasDLTG f6bc46dd98 fix; compressoor steppers 2026-09-13 10:06:48 +02:00
LucasDLTG 30e1c0b0a9 feat: wheel behaviour on choiceselect 2026-09-13 09:08:42 +02:00
LucasDLTG 9f2ace9698 feat: use new meter format 2026-09-13 09:04:04 +02:00
LucasDLTG 1e00ca3a7c feat: remove state and let rust handle 2026-09-12 19:37:59 +02:00
LucasDLTG 2717dfe99a feat: prevent text selection on dsp sub pages 2026-09-12 15:19:57 +02:00
LucasDLTG fd545fb2a8 feat: prevent text selection on main pages 2026-09-12 14:14:47 +02:00
LucasDLTG 3793ee9f5b feat: prevent text selection on add dsp page 2026-09-12 13:35:02 +02:00
LucasDLTG 014ccf5197 fix: style of add page selector 2026-09-12 11:52:36 +02:00
LucasDLTG 0f198692ad fix: draw over bounds 2026-09-12 11:22:27 +02:00
LucasDLTG 19c3aedf80 fix: panel on all pages 2026-09-12 10:12:17 +02:00
LucasDLTG 9589f6f3f0 fix: gate panels on linux 2026-09-11 20:27:55 +02:00
LucasDLTG 037d17ff3d feat: graph header on gain panel 2026-09-09 20:56:35 +02:00
LucasDLTG 20182a8e36 fix: font weigth on 'add page' 2026-09-09 17:24:12 +02:00
LucasDLTG 8f5a7c22cc fix: linux display main page 2026-09-08 21:57:38 +02:00
LucasDLTG ac23f98fcd feat: push Topbar 2026-09-06 12:54:45 +02:00
LucasDLTG 81a99b7173 feat: update 2026-09-06 12:45:27 +02:00
LucasDLTG 75ac349790 refactor: graph to use a set of points instead of taking the whole dsp oobject 2026-08-31 20:14:07 +02:00
LucasDLTG 21728b5e6d fix: eslint errors 2026-08-31 14:12:36 +02:00
LucasDLTG d6d0215e09 feat: add peq foor input 2026-08-31 14:12:03 +02:00
LucasDLTG a0c9fbb674 feat: add behaviour to hp/lp filters 2026-08-13 11:32:06 +02:00
LucasDLTG a28dd890dd feat: start input section 2026-08-12 22:19:09 +02:00
LucasDLTG 00adc4df53 feat: add matrix section 2026-08-12 18:45:57 +02:00
LucasDLTG eb751f9404 feat: round max on delay graph 2026-08-12 12:23:08 +02:00
LucasDLTG 5945b48b98 feat: improve compressor graph 2026-08-12 12:19:17 +02:00
LucasDLTG ab9de39556 fix: error js 2026-08-12 11:57:39 +02:00
LucasDLTG ddec10ca8f feat: add geq graph + bypass warning 2026-08-12 11:51:26 +02:00
LucasDLTG 34fad6f905 refactor: folders/files to group dsp logic 2026-08-11 23:17:47 +02:00
LucasDLTG d91bcaa20f feat: add demo mode 2026-08-11 22:02:46 +02:00
LucasDLTG b572e1c794 feat: start in tab 2026-08-10 17:58:55 +02:00
LucasDLTG 0f66ed72f4 feat: custom input formatting + custom step 2026-08-10 15:29:18 +02:00
LucasDLTG ee3f56fcdf feat: remove useless api file 2026-08-07 16:02:39 +02:00
LucasDLTG fbcaaf31aa feat: remove useless api file 2026-08-06 21:14:41 +02:00
LucasDLTG 470b95ace3 feat: add delay tab 2026-08-06 20:51:38 +02:00
LucasDLTG e802067056 feat: add limiter 2026-08-06 16:15:46 +02:00
LucasDLTG 56f7aca174 fix: warnings 2026-08-06 14:13:37 +02:00
LucasDLTG 78ea04453e feat: add compressor + fix ui for all sections 2026-08-06 14:13:04 +02:00
LucasDLTG e110a28f80 fix: warnings 2026-07-31 13:25:38 +02:00
LucasDLTG daf2bf32aa feat: gate sectioon 2026-07-31 13:24:24 +02:00
LucasDLTG 06710b68d4 feat: responsive gain section 2026-07-28 23:01:30 +02:00
LucasDLTG 55bffbe350 fix: illegal button inside button is header 2026-07-28 22:53:43 +02:00
LucasDLTG 7ad2db9662 feat: add click on tab led for connect 2026-07-28 18:57:06 +02:00
LucasDLTG 255b1f217e feat: improve gain section 2026-07-28 18:41:40 +02:00
LucasDLTG eab62e151e feat: dont moove slider cursor if tcp command fails 2026-07-28 13:53:57 +02:00
LucasDLTG 54fda168cc feat: sidebar allows recall, rename and display custom names 2026-07-28 12:39:13 +02:00
LucasDLTG da322c9df1 feat: add recall preset 2026-07-27 19:13:52 +02:00
LucasDLTG 31f645ebb2 feat: add keep alive 2026-07-26 17:56:25 +02:00
LucasDLTG a157b5f198 feat: responsive connection section 2026-07-24 22:25:50 +02:00
LucasDLTG 62d3b22fea feat: responsive sidebar 2026-07-24 21:55:28 +02:00
162 changed files with 17500 additions and 5068 deletions
+24 -24
View File
@@ -1,24 +1,24 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "es5",
"printWidth": 100, "printWidth": 100,
"tabWidth": 2 "tabWidth": 2
} }
+3 -3
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
} }
+31 -7
View File
@@ -1,7 +1,31 @@
# Tauri + React + Typescript # Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite. This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup ## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) - [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
- Check render to improve perfoormances
- Check in meter if clip bool is limit instead
- On close disconenct all ds^p
- Check end to end behavioour
- Roll back on scroll when 2 scroll
- test en dto end connection
Data: 10020001052f 31313131 10032a // lock
Data: 10020001052d 31313131 100328 // unlock fail
Data: 10020100032d 3100 10031f
Data: 10020001062d 0031313131 10032b // unlock success
Data: 10020100032d 0001 10032f
+14 -14
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Tauri + React + Typescript</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+3077 -3131
View File
File diff suppressed because it is too large Load Diff
+31 -31
View File
@@ -1,31 +1,31 @@
{ {
"name": "thomann-dsp-app", "name": "thomann-dsp-app",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\""
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
} }
+7 -7
View File
@@ -1,7 +1,7 @@
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
# Generated by Tauri # Generated by Tauri
# will have schema files for capabilities auto-completion # will have schema files for capabilities auto-completion
/gen/schemas /gen/schemas
+190 -43
View File
@@ -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.19", "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,11 +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",
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror 2.0.19", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -1254,6 +1313,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -1761,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"
@@ -2004,7 +2118,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.19", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2492,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"
@@ -2605,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"
@@ -2628,7 +2774,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.19", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -2648,7 +2794,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2882,7 +3028,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2917,7 +3063,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2940,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",
@@ -2960,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]]
@@ -3008,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",
] ]
@@ -3198,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",
@@ -3299,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",
@@ -3338,7 +3485,7 @@ dependencies = [
"tauri-runtime", "tauri-runtime",
"tauri-runtime-wry", "tauri-runtime-wry",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"tokio", "tokio",
"tray-icon", "tray-icon",
"url", "url",
@@ -3389,7 +3536,7 @@ dependencies = [
"sha2", "sha2",
"syn 2.0.119", "syn 2.0.119",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"time", "time",
"url", "url",
"uuid", "uuid",
@@ -3428,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",
@@ -3442,7 +3589,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"windows", "windows",
"zbus", "zbus",
@@ -3466,7 +3613,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com",
@@ -3529,7 +3676,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"swift-rs", "swift-rs",
"thiserror 2.0.19", "thiserror 2.0.21",
"toml 1.1.3+spec-1.1.0", "toml 1.1.3+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern",
@@ -3581,11 +3728,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" 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 = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl 2.0.19", "thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@@ -3601,13 +3748,13 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.19" 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 = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.2", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -3918,7 +4065,7 @@ dependencies = [
"once_cell", "once_cell",
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.19", "thiserror 2.0.21",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -4278,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.19", "thiserror 2.0.21",
"windows", "windows",
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
@@ -4713,7 +4860,7 @@ dependencies = [
"sha2", "sha2",
"soup3", "soup3",
"tao-macros", "tao-macros",
"thiserror 2.0.19", "thiserror 2.0.21",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
+3 -3
View File
@@ -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"] }
+3 -3
View File
@@ -1,3 +1,3 @@
fn main() { fn main() {
tauri_build::build() tauri_build::build()
} }
+10 -10
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default"
] ]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+87
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+448 -79
View File
@@ -1,23 +1,17 @@
use tauri::State;
use crate::state::{AppState, DSPDevice}; use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::DSP408; use dsp_thomann::dsp408::types::DelayUnit;
use std::{net::IpAddr, str::FromStr}; use dsp_thomann::dsp408::{types, DSP408};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::{net::IpAddr, str::FromStr};
use tauri::State;
impl AppState { impl AppState {
pub fn with_device_mut<F, R>( pub fn with_device_mut<F, R>(&self, id: u64, f: F) -> Result<R, String>
&self,
id: u64,
f: F,
) -> Result<R, String>
where where
F: FnOnce(&mut DSPDevice) -> Result<R, String>, F: FnOnce(&mut DSPDevice) -> Result<R, String>,
{ {
let device = { let device = {
let devices = self let devices = self.devices.lock().map_err(|e| e.to_string())?;
.devices
.lock()
.map_err(|e| e.to_string())?;
devices devices
.get(&id) .get(&id)
@@ -25,22 +19,14 @@ impl AppState {
.ok_or_else(|| "DSP not found".to_string())? .ok_or_else(|| "DSP not found".to_string())?
}; };
let mut device = device let mut device = device.lock().map_err(|e| e.to_string())?;
.lock()
.map_err(|e| e.to_string())?;
f(&mut device) f(&mut device)
} }
pub fn remove_device( pub fn remove_device(&self, id: u64) -> Result<(), String> {
&self,
id: u64,
) -> Result<(), String> {
let device = { let device = {
let mut devices = self.devices let mut devices = self.devices.lock().map_err(|e| e.to_string())?;
.lock()
.map_err(|e| e.to_string())?;
devices devices
.remove(&id) .remove(&id)
@@ -55,57 +41,48 @@ impl AppState {
} }
} }
#[derive(serde::Serialize)]
pub struct DSP408Snapshot {
pub state: types::DSPState,
pub input_meters: types::InputMeters,
pub output_meters: types::OutputMeters,
}
#[tauri::command] #[tauri::command]
pub fn create_dsp408( pub fn create_dsp408(
state: State<AppState>, state: State<AppState>,
ip: String, ip: String,
port: u16, port: u16,
device_id: u8, device_id: u8,
demo: bool,
) -> Result<u64, String> { ) -> Result<u64, String> {
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
let ip = IpAddr::from_str(&ip) let dsp = if demo {
.map_err(|e| e.to_string())?; DSP408::new_demo()
} else {
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
};
let dsp = DSP408::new(ip, port, device_id) let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
let mut next_id = state
.next_id
.lock()
.map_err(|e| e.to_string())?;
let id = *next_id; let id = *next_id;
*next_id += 1; *next_id += 1;
let device = DSPDevice { let device = DSPDevice { dsp };
dsp,
};
let mut devices = state let mut devices = state.devices.lock().map_err(|e| e.to_string())?;
.devices
.lock()
.map_err(|e| e.to_string())?;
devices.insert( devices.insert(id, Arc::new(Mutex::new(device)));
id,
Arc::new(Mutex::new(device)),
);
Ok(id) Ok(id)
} }
#[tauri::command] #[tauri::command]
pub async fn remove_dsp408( pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state: State<'_, AppState>,
id: u64,
) -> Result<(), String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
if device.dsp.connected() { if device.dsp.connected() {
device.dsp device.dsp.disconnect().map_err(|e| e.to_string())?;
.disconnect()
.map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
@@ -117,28 +94,16 @@ pub async fn remove_dsp408(
} }
#[tauri::command] #[tauri::command]
pub async fn connect_dsp408( pub async fn get_dsp_snapshot(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
) -> Result<(), String> { ) -> Result<DSP408Snapshot, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
device.dsp Ok(DSP408Snapshot {
.connect() state: device.dsp.state().clone(),
.map_err(|e| e.to_string()) input_meters: device.dsp.input_meters().clone(),
}) output_meters: device.dsp.output_meters().clone(),
} })
#[tauri::command]
pub async fn disconnect_dsp408(
state: State<'_, AppState>,
id: u64,
) -> Result<(), String> {
state.with_device_mut(id, |device| {
device.dsp
.disconnect()
.map_err(|e| e.to_string())
}) })
} }
@@ -147,16 +112,221 @@ pub async fn get_dsp_state(
state: State<'_, AppState>, state: State<'_, AppState>,
id: u64, id: u64,
) -> Result<dsp_thomann::dsp408::types::DSPState, String> { ) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let dsp_state = device.dsp let dsp_state = device.dsp.state();
.state()
.map_err(|e| e.to_string())?;
Ok(dsp_state.clone()) Ok(dsp_state.clone())
}) })
} }
// Commands
#[tauri::command]
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
}
// Command `0x11`
#[tauri::command]
pub async fn disconnect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
state.with_device_mut(id, |device| {
device.dsp.disconnect().map_err(|e| e.to_string())
})
}
// Command `0x15`
#[tauri::command]
pub async fn set_delay_unit(
state: State<'_, AppState>,
id: u64,
unit: types::DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.set_delay_unit(unit).map_err(|e| e.to_string())
})
}
// Command `0x20`
#[tauri::command]
pub async fn recall_preset(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let success = device
.dsp
.recall_preset(preset_index)
.map_err(|e| e.to_string())?;
Ok(success)
})
}
// Command `0x21` -- TODO
#[tauri::command]
pub async fn store_current_configuration(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.store_current_configuration(preset_index, force)
.map_err(|e| e.to_string())
})
}
// Command `0x23`
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device.dsp.factory_reset(force).map_err(|e| e.to_string())
})
}
// Command `0x26`
#[tauri::command]
pub async fn set_current_preset_name(
state: State<'_, AppState>,
id: u64,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_current_preset_name(&name)
.map_err(|e| e.to_string())
})
}
// Command `0x2A`
#[tauri::command]
pub async fn copy_channel(
state: State<'_, AppState>,
id: u64,
channel_1: types::Channel,
channel_2: types::Channel,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string())
})
}
// Command `0x2D` -- TODO
#[tauri::command]
pub async fn unlock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.unlock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x2F`
#[tauri::command]
pub async fn lock_device(
state: State<'_, AppState>,
id: u64,
password: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.lock_device(&password)
.map_err(|e| e.to_string())
})
}
// Command `0x30`
#[tauri::command]
pub async fn set_compressor(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
ratio: types::Ratio,
attack: u16,
release: u16,
knee: u16,
threshold: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_compressor(channel, ratio, attack, release, knee, threshold)
.map_err(|e| e.to_string())
})
}
// Command `0x31`
#[tauri::command]
pub async fn set_low_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_low_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
// Command `0x32`
#[tauri::command]
pub async fn set_high_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_high_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
// Command `0x33`
#[tauri::command]
pub async fn set_peq_band(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
band: types::PEQBand,
gain_db: f32,
freq_hz: f32,
q: f32,
filter_type: types::PEQFilter,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_peq_band(channel, band, gain_db, freq_hz, q, filter_type, bypass)
.map_err(|e| e.to_string())
})
}
// Command `0x34`
#[tauri::command] #[tauri::command]
pub async fn set_channel_gain( pub async fn set_channel_gain(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -165,7 +335,8 @@ pub async fn set_channel_gain(
db: f32, db: f32,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let r = device.dsp let r = device
.dsp
.set_channel_gain(channel, db) .set_channel_gain(channel, db)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -173,6 +344,7 @@ pub async fn set_channel_gain(
}) })
} }
// Command `0x35`
#[tauri::command] #[tauri::command]
pub async fn set_mute( pub async fn set_mute(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -181,7 +353,8 @@ pub async fn set_mute(
muted: bool, muted: bool,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let r = device.dsp let r = device
.dsp
.set_mute(channel, muted) .set_mute(channel, muted)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -189,6 +362,7 @@ pub async fn set_mute(
}) })
} }
// Command `0x36`
#[tauri::command] #[tauri::command]
pub async fn set_channel_inverse_gain( pub async fn set_channel_inverse_gain(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -197,10 +371,205 @@ pub async fn set_channel_inverse_gain(
inverted: bool, inverted: bool,
) -> Result<bool, String> { ) -> Result<bool, String> {
state.with_device_mut(id, |device| { state.with_device_mut(id, |device| {
let r = device.dsp let r = device
.dsp
.set_channel_inverse_gain(channel, inverted) .set_channel_inverse_gain(channel, inverted)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(r) Ok(r)
}) })
} }
// Command `0x38`
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
// Command `0x39`
#[tauri::command]
pub async fn set_input_source(
state: State<'_, AppState>,
id: u64,
input_source: types::InputSource,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_input_source(input_source)
.map_err(|e| e.to_string())
})
}
// Command `0x3A`
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
// Command `0x3B`
#[tauri::command]
pub async fn link_channels(
state: State<'_, AppState>,
id: u64,
source: types::Channel,
destinations: Vec<types::Channel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.link_channels(source, destinations)
.map_err(|e| e.to_string())
})
}
// Command `0x3C`
#[tauri::command]
pub async fn set_bypass_peq(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_bypass_peq(channel, bypass)
.map_err(|e| e.to_string())
})
}
// Command `0x3D` -- TODO
#[tauri::command]
pub async fn set_channel_name(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_channel_name(channel, &name)
.map_err(|e| e.to_string())
})
}
// Command `0x3E`
#[tauri::command]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
// Command `0x3F`
#[tauri::command]
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
// Command `0x40`
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<(types::InputMeters, types::OutputMeters), String> {
state.with_device_mut(id, |device| {
device.dsp.get_meter_levels().map_err(|e| e.to_string())
})
}
// Command `0x41`
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x48`
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
// Command `0x49`
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
+35 -11
View File
@@ -1,5 +1,5 @@
mod state;
mod commands; mod commands;
mod state;
use state::AppState; use state::AppState;
@@ -9,16 +9,40 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.manage(AppState::new()) .manage(AppState::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::create_dsp408, commands::create_dsp408,
commands::connect_dsp408, commands::remove_dsp408,
commands::remove_dsp408, commands::get_dsp_snapshot,
commands::disconnect_dsp408, commands::get_dsp_state,
commands::get_dsp_state, commands::connect_dsp408, // 0x10
commands::set_channel_gain, commands::disconnect_dsp408, // 0x11
commands::set_mute, commands::set_delay_unit, // 0x15
commands::set_channel_inverse_gain commands::recall_preset, // 0x20
] commands::store_current_configuration, // 0x21
) commands::factory_reset, // 0x23
commands::set_current_preset_name, // 0x26
commands::copy_channel, // 0x2A
commands::unlock_device, // Command `0x2D`
commands::lock_device, // Command `0x2F`
commands::set_compressor, // 0x30
commands::set_low_pass, // 0x31
commands::set_high_pass, // 0x32
commands::set_peq_band, // 0x33
commands::set_channel_gain, // 0x34
commands::set_mute, // 0x35
commands::set_channel_inverse_gain, // 0x36
commands::set_delay, // 0x38
commands::set_input_source, // 0x39
commands::set_matrix_routing, // 0x3A
commands::link_channels, // 0x3B
commands::set_bypass_peq, // 0x3C
commands::set_channel_name, // 0x3D
commands::set_gate, // 0x3E
commands::set_limiter, // 0x3F
commands::get_meter_levels, // 0x40
commands::set_matrix_gain, // 0x41
commands::set_geq_band, // 0x48
commands::set_geq_bypass, // 0x49
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+6 -6
View File
@@ -1,6 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
thomann_dsp_app_lib::run() thomann_dsp_app_lib::run()
} }
+3 -3
View File
@@ -1,6 +1,6 @@
use std::sync::{Mutex, Arc};
use std::collections::HashMap;
use dsp_thomann::dsp408::DSP408; use dsp_thomann::dsp408::DSP408;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub struct DSPDevice { pub struct DSPDevice {
pub dsp: DSP408, pub dsp: DSP408,
@@ -18,4 +18,4 @@ impl AppState {
next_id: Mutex::new(1), next_id: Mutex::new(1),
} }
} }
} }
+37 -37
View File
@@ -1,37 +1,37 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "thomann-dsp-app", "productName": "thomann-dsp-app",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.lucas.thomann-dsp-app", "identifier": "com.lucas.thomann-dsp-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "thomann-dsp-app", "title": "Thomann DSP Controller",
"width": 1200, "width": 1600,
"height": 800, "height": 950,
"minWidth": 900, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
], ],
"security": { "security": {
"csp": null "csp": null
} }
}, },
"bundle": { "bundle": {
"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"
] ]
} }
} }
View File
+31 -124
View File
@@ -1,128 +1,40 @@
import { invoke } from '@tauri-apps/api/core'; import { useState, useEffect, useRef } from 'react';
import './App.css';
import { useState } from 'react'; import { DSP, AppPage, isConnected } from './types';
import './styles/App.css';
import { DSP, DSPStatus, AppPage } from './types/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';
import { useNotifications } from './hooks/useNotifications'; import { useNotifications } from './hooks/useNotifications';
import Notifications from './components/Notifications/Notifications'; import Notifications from './components/Notifications/Notifications';
import { DSPState } from './types/dsp408State';
import CreditsPage from './pages/CreditsPage/CreditsPage'; import CreditsPage from './pages/CreditsPage/CreditsPage';
import HomePage from './pages/HomePage/HomePage'; import HomePage from './pages/HomePage/HomePage';
import { addDSP, createDSPInstance } from './dsp/dspApi';
import { removeDSP } from './dsp/dspShared';
import { stopAllPolling } from './dsp/pollRegistry';
function App() { function App() {
const [dsps, setDsps] = useState<DSP[]>([]); const [dsps, setDsps] = useState<DSP[]>([]);
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) => isConnected(dsp)).length;
async function addDSP(dsp: Omit<DSP, 'id' | 'status'>) {
try {
const id = await invoke<number>('create_dsp408', {
ip: dsp.ip,
port: dsp.port,
deviceId: Number(dsp.deviceId),
});
const newDsp: DSP = {
id,
...dsp,
status: 'disconnected',
};
setDsps((prev) => [...prev, newDsp]);
setSelected(id);
setPage('view-dsp');
notify('Added successfully', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}
async function removeDSP(dsp: DSP) {
try {
let id = dsp.id;
await invoke('remove_dsp408', {
id,
});
setDsps((prev) => {
const next = prev.filter((d) => d.id !== id);
if (selected === id) {
setSelected(next.length ? next[0].id : null);
}
return next;
});
notify('Removed successfully', 'success', dsp?.name);
} catch (err) {
notify(`Remove failed: ${String(err)}`, 'error', dsp?.name);
}
}
async function connectDSP(dsp: DSP) {
setStatus(dsp.id, 'connecting');
try {
await invoke('connect_dsp408', {
id: dsp.id,
});
console.log('Connected');
setStatus(dsp.id, 'connected');
const state = await getDSPState(dsp);
console.log(state);
notify('Connected', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
setStatus(dsp.id, 'disconnected');
}
}
async function disconnectDSP(dsp: DSP) {
try {
await invoke('disconnect_dsp408', {
id: dsp.id,
});
setStatus(dsp.id, 'disconnected');
notify('Disconnected', 'success', dsp.name);
} catch (err) {
notify(`Disconnect failed: ${String(err)}`, 'error', dsp.name);
}
}
async function getDSPState(dsp: DSP) {
try {
const state = await invoke<DSPState>('get_dsp_state', {
id: dsp.id,
});
return state;
} catch (err) {
notify(`Failed to get state: ${String(err)}`, 'error', dsp.name);
return null;
}
}
function setStatus(id: number, status: DSPStatus) {
setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d)));
}
const activeDsp = dsps.find((d) => d.id === selected) ?? null; const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
const dspsRef = useRef(dsps);
dspsRef.current = dsps;
useEffect(() => {
return () => stopAllPolling(dspsRef.current);
}, []);
useEffect(() => {
if (activeDsp == null) return;
const instance = createDSPInstance(activeDsp, setDsps, notify);
instance.api.setActive(true);
return () => instance.api.setActive(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDsp?.id]);
return ( return (
<div className="app"> <div className="app">
@@ -138,31 +50,26 @@ function App() {
page={page} page={page}
setPage={setPage} setPage={setPage}
setSelect={setSelected} setSelect={setSelected}
onSelect={(id) => { onSelect={(id) => {
setSelected(id); setSelected(id);
setPage('view-dsp'); setPage('view-dsp');
}} }}
onRemove={(dsp) => removeDSP(setDsps, selected, setSelected, dsp.id, dsp.type)}
onRemove={removeDSP} onAddClick={() => setPage('add-dsp')}
onConnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.connect()}
onAddClick={() => { onDisconnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.disconnect()}
setPage('add-dsp');
}}
/> />
<main className="stage"> <main className="stage">
{page === 'credits' ? ( {page === 'credits' ? (
<CreditsPage /> <CreditsPage />
) : page === 'add-dsp' ? ( ) : page === 'add-dsp' ? (
<AddDSPForm onCancel={() => setPage('home')} onAdd={addDSP} /> <AddDSPForm
) : activeDsp ? ( onCancel={() => setPage('home')}
<DSPPage onAdd={(params) => addDSP(setDsps, setSelected, setPage, notify, params)}
dsp={activeDsp}
onConnect={() => connectDSP(activeDsp)}
onDisconnect={() => disconnectDSP(activeDsp)}
notify={notify}
/> />
) : activeInstance ? (
<DSPPage instance={activeInstance} />
) : ( ) : (
<HomePage <HomePage
onAddDSP={() => setPage('add-dsp')} onAddDSP={() => setPage('add-dsp')}
+169
View File
@@ -64,3 +64,172 @@ export function KnobGainIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export function TextIcon({
children,
...props
}: React.SVGProps<SVGSVGElement> & { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" {...props}>
<text
x="12"
y="12"
textAnchor="middle"
dominantBaseline="middle"
fontSize="9.5"
fill="currentColor"
>
{children}
</text>
</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>
);
}
+42
View File
@@ -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>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 3L2 12H14L8 3Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 13L2 4H14L8 13Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
+77 -5
View File
@@ -2,21 +2,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
min-height: var(--card-control-height); min-height: var(--card-control-height);
padding: 0 var(--space-4); padding: 0 var(--space-4);
border: var(--border-width) solid transparent; border: var(--border-width) solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-md); font-size: 13px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-select: none;
} }
.btn:disabled { .btn:disabled {
@@ -24,15 +24,87 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ------------------------------------------------------------------ */
/* Variants */
/* ------------------------------------------------------------------ */
.btn-primary { .btn-primary {
background: var(--accent-brand); background: var(--accent-brand);
color: var(--bg-void); color: var(--bg-void);
} }
/* Ghost + Toggle */
.btn-ghost, .btn-ghost,
.btn-toggle, .btn-toggle {
.btn-outline {
background: transparent; background: transparent;
border-color: var(--border-hairline); border-color: var(--border-hairline);
color: var(--text-muted); color: var(--text-muted);
} }
/* Outline */
.btn-outline {
background: transparent;
border-color: var(--accent-brand);
color: #fff;
}
/* ------------------------------------------------------------------ */
/* Hover */
/* ------------------------------------------------------------------ */
.btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-brand) 95%, white);
color: var(--text-primary);
}
.btn-ghost:hover:not(:disabled),
.btn-toggle:hover:not(:disabled) {
background: var(--bg-raised);
color: var(--text-primary);
}
.btn-outline:hover:not(:disabled) {
border-color: var(--accent-brand);
color: var(--text-muted);
}
/* ------------------------------------------------------------------ */
/* Active toggle states */
/* ------------------------------------------------------------------ */
.btn-toggle.active-info {
background: var(--accent-brand);
border-color: var(--accent-brand);
color: var(--bg-void);
}
.btn-toggle.active-danger {
background: var(--accent-down);
border-color: var(--accent-down);
color: var(--bg-void);
}
.btn-toggle.active-warning {
background: var(--accent-connecting);
border-color: var(--accent-connecting);
color: var(--bg-void);
}
/* ------------------------------------------------------------------ */
/* Active toggle hover */
/* ------------------------------------------------------------------ */
.btn-toggle.active-info:hover:not(:disabled) {
background: var(--accent-brand);
border-color: var(--accent-brand);
}
.btn-toggle.active-danger:hover:not(:disabled) {
background: var(--accent-down);
border-color: var(--accent-down);
}
.btn-toggle.active-warning:hover:not(:disabled) {
background: var(--accent-connecting);
border-color: var(--accent-connecting);
}
-3
View File
@@ -7,7 +7,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
fontSize?: number;
} }
export default function Button({ export default function Button({
@@ -15,7 +14,6 @@ export default function Button({
children, children,
className = '', className = '',
disabled, disabled,
fontSize = 13,
type = 'button', type = 'button',
style, style,
...props ...props
@@ -26,7 +24,6 @@ export default function Button({
className={`btn btn-${variant} ${className}`} className={`btn btn-${variant} ${className}`}
disabled={disabled} disabled={disabled}
style={{ style={{
fontSize: `${fontSize}px`,
...style, ...style,
}} }}
{...props} {...props}
+1 -1
View File
@@ -12,7 +12,7 @@
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-size-sm); font-size: var(--font-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -0,0 +1,47 @@
.channel-group {
position: relative;
flex: 0 0 auto;
width: fit-content;
min-width: 0;
min-height: 0;
display: flex;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
padding: 6cqh var(--space-3) 1cqh;
margin-bottom: 1cqh;
margin-top: 1cqh;
box-sizing: border-box;
overflow: visible;
}
.channel-group__label {
position: absolute;
top: 1cqh;
left: 50%;
transform: translateX(-50%);
padding: 0 var(--space-2);
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
white-space: nowrap;
line-height: 1;
}
.channel-group__items {
display: flex;
flex-direction: row;
align-items: stretch;
width: fit-content;
height: 100%;
gap: var(--space-1);
}
@@ -0,0 +1,125 @@
/* ChoiceSelect.css */
.choice-select {
position: relative;
display: flex;
color: var(--accent-brand);
}
.choice-select[data-disabled] {
opacity: 0.5;
}
.choice-select__trigger {
flex: 1 1 auto;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
padding-inline: 5%;
background: transparent;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 90%;
color: inherit;
cursor: pointer;
transition: border-color var(--dur-base) var(--ease-standard);
}
.choice-select__trigger:hover,
.choice-select__trigger--open {
border-color: var(--accent-brand);
}
.choice-select__trigger:disabled {
cursor: not-allowed;
}
.choice-select__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.choice-select__chevron {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 4cqh;
aspect-ratio: 1;
color: var(--accent-brand);
transition: transform var(--dur-base) var(--ease-standard);
}
.choice-select__trigger--open .choice-select__chevron {
transform: rotate(180deg);
}
.choice-select__chevron svg {
width: 50%;
height: auto;
display: block;
}
.choice-select__option {
padding: 1%;
border-radius: var(--radius-sm);
font-size: 100%;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-select__option--highlight {
background: var(--bg-raised);
}
.choice-select__option--selected {
color: var(--accent-brand);
font-weight: var(--font-weight-semibold);
}
.choice-select__listbox {
position: absolute;
left: 0;
right: 0;
z-index: 40;
top: calc(100% + var(--space-1));
margin: 0;
padding: var(--space-1);
list-style: none;
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-height: 240px;
overflow-y: auto;
}
.choice-select__listbox--up {
top: auto;
bottom: calc(100% + var(--space-1));
}
.choice-select__listbox--portal {
position: fixed;
z-index: 1000; /* clear of any local stacking contexts in the panel */
}
@@ -0,0 +1,232 @@
// ChoiceSelect.tsx
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceSelect.css';
type ChoiceSelectProps<T extends string> = {
value: T;
options: readonly T[];
formatLabel?: (value: T) => string;
onChange: (value: T) => void;
id?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
};
const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height
const LISTBOX_GAP = 4; // px, ~= var(--space-1)
type Coords = { top: number; left: number; width: number; fontSize: number };
function ChoiceSelect<T extends string>({
value,
options,
formatLabel = (v) => v,
onChange,
id,
disabled,
className = '',
style,
}: ChoiceSelectProps<T>) {
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const [dropUp, setDropUp] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const suppressNextClick = useRef(false);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
const target = e.target as Node;
const clickedOutside =
rootRef.current &&
!rootRef.current.contains(target) &&
!listboxRef.current?.contains(target);
if (clickedOutside && open) {
setOpen(false);
suppressNextClick.current = true;
}
}
function handleClickCapture(e: MouseEvent) {
if (suppressNextClick.current) {
e.preventDefault();
e.stopPropagation();
suppressNextClick.current = false;
}
}
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('click', handleClickCapture, true); // capture phase
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('click', handleClickCapture, true);
};
}, [open]);
// Re-check placement/position on scroll/resize while open — the trigger's
// position can change (e.g. inside a scrollable panel), and since the
// listbox is portaled it won't move with it automatically.
useEffect(() => {
if (!open) return;
const recalc = () => updatePlacement();
window.addEventListener('scroll', recalc, true);
window.addEventListener('resize', recalc);
return () => {
window.removeEventListener('scroll', recalc, true);
window.removeEventListener('resize', recalc);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const step = (direction: 1 | -1) => {
const currentIndex = options.indexOf(value);
if (currentIndex === -1 || options.length === 0) return;
const nextIndex = (currentIndex + direction + options.length) % options.length;
onChange(options[nextIndex]);
};
function updatePlacement() {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
setDropUp(needsFlip);
// Capture the trigger's resolved font-size (its cqh has already resolved
// correctly here, since it's still inside the real container tree) and
// carry it as a plain px value into the portal, where cqh would break.
const fontSize = parseFloat(getComputedStyle(el).fontSize);
setCoords({
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
left: rect.left,
width: rect.width,
fontSize,
});
}
function openList() {
if (disabled) return;
updatePlacement();
setOpen(true);
const idx = options.indexOf(value);
setHighlight(idx >= 0 ? idx : 0);
}
function pick(opt: T) {
if (opt !== value) onChange(opt);
setOpen(false);
triggerRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (disabled) return;
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
openList();
}
return;
}
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, options.length - 1));
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
pick(options[highlight]);
} else if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
}
return (
<div
className={`choice-select ${className}`}
data-disabled={disabled || undefined}
ref={rootRef}
style={style}
>
<button
id={id}
type="button"
ref={triggerRef}
className={'choice-select__trigger' + (open ? ' choice-select__trigger--open' : '')}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={handleKeyDown}
onWheel={(e) => {
e.preventDefault();
if (disabled) return;
if (e.deltaY < 0) step(-1);
else if (e.deltaY > 0) step(1);
}}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="choice-select__label">{formatLabel(value)}</span>
<span className="choice-select__chevron" aria-hidden="true">
<TriangleDownIcon />
</span>
</button>
{open &&
coords &&
createPortal(
<ul
ref={listboxRef}
className={
'choice-select__listbox choice-select__listbox--portal' +
(dropUp ? ' choice-select__listbox--up' : '')
}
role="listbox"
style={{
fontSize: coords.fontSize,
left: coords.left,
width: coords.width,
// dropUp needs top: auto + bottom instead, see CSS note below
...(dropUp
? { top: 'auto', bottom: window.innerHeight - coords.top }
: { top: coords.top }),
}}
>
{options.map((option, i) => (
<li
key={option}
role="option"
aria-selected={option === value}
className={
'choice-select__option' +
(option === value ? ' choice-select__option--selected' : '') +
(i === highlight ? ' choice-select__option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(option)}
>
{formatLabel(option)}
</li>
))}
</ul>,
document.body
)}
</div>
);
}
export default ChoiceSelect;
@@ -0,0 +1,92 @@
.choice-stepper {
width: 100%;
flex: 1 1 auto;
position: relative;
display: flex;
align-items: stretch;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
background: transparent;
}
.choice-stepper__input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
font-family: var(--font-mono);
font-size: var(--font--sm);
font-weight: var(--font-weight-normal);
text-align: left;
color: var(--accent-brand);
background: transparent;
border: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-stepper__input:focus {
outline: none;
}
.choice-stepper__arrows {
flex: 0 0 auto;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
box-sizing: border-box;
padding-right: 1%;
}
.choice-stepper__arrow-btn {
height: 50%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--accent-brand);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
}
.choice-stepper__arrow-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choice-stepper__arrow-btn svg {
display: block;
height: 100%;
}
.choice-stepper[data-disabled] {
opacity: 0.45;
filter: grayscale(1);
}
.choice-stepper__input:disabled {
cursor: not-allowed;
}
.choice-stepper[data-disabled] .choice-stepper__input,
.choice-stepper[data-disabled] .choice-stepper__arrow-btn {
color: var(--border-hairline);
}
@@ -0,0 +1,427 @@
import { JSX, useEffect, useRef, useState } from 'react';
import Button from '../Button/Button';
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceStepper.css';
const WHEEL_COMMIT_DELAY = 250;
// Safety net: if the parent's `value` prop never comes back exactly equal
// to what we committed (rounding, an intermediate transform, etc.), stop
// waiting after this long so the stepper doesn't get stuck ignoring
// external updates.
const PENDING_FALLBACK_MS = 1000;
type CommitResult = Promise<boolean | void> | boolean | void;
function useNonPassiveWheel(
ref: React.RefObject<HTMLElement | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: WheelEvent) => handlerRef.current(e);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
function StepperArrows({
onUp,
onDown,
canUp,
canDown,
disabled,
}: {
onUp: () => void;
onDown: () => void;
canUp: boolean;
canDown: boolean;
disabled?: boolean;
}) {
return (
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onUp}
disabled={disabled || !canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={onDown}
disabled={disabled || !canDown}
>
<TriangleDownIcon />
</Button>
</div>
);
}
// ---- List variant ----
type ChoiceStepperListProps<T> = {
value: T;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
};
function ChoiceStepperList<T>({
value,
values,
formatLabel,
onChange,
id,
disabled,
className,
}: ChoiceStepperListProps<T>) {
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value));
// True from the moment a commit starts until the prop `value` we get from
// the parent actually reflects it (not just until onChange resolves —
// those are two different moments and the gap between them is what was
// causing the old-value blink).
const interacting = useRef(false);
// The value we're waiting to see echoed back via the `value` prop.
const pendingValue = useRef<T | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
// Only release control once the prop actually catches up to what we
// committed — otherwise keep showing localValue as-is (no flash back
// to the stale old value in between).
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const index = values.indexOf(localValue);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const stepLocal = (direction: 1 | -1): T | null => {
const currentIndex = values.indexOf(localValue);
if (currentIndex === -1) return null;
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= values.length) return null;
const next = values[nextIndex];
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: T) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
// Accepted: don't clear `interacting` here. Wait for the prop-sync
// effect above to see `value === next` and release it, so we never
// hand control back to a still-stale prop. Guard with a timeout in
// case it never matches exactly.
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) {
setLocalValue(match);
void commit(match);
} else {
setText(formatLabel(value));
}
};
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
interacting.current = true;
stepLocal(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
interacting.current = true;
stepLocal(-1);
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
/>
</div>
);
}
// ---- Range variant (concrete, no generic) ----
type ChoiceStepperRangeProps = {
value: number;
min: number;
max: number;
step?: number;
formatLabel: (value: number) => string;
onChange: (value: number) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
};
function ChoiceStepperRange({
value,
min,
max,
step: stepSize = 1,
formatLabel,
onChange,
id,
disabled,
className,
}: ChoiceStepperRangeProps) {
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value));
const interacting = useRef(false);
const pendingValue = useRef<number | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useNonPassiveWheel(inputRef, (e) => {
e.preventDefault();
if (e.deltaY < 0 && canUp) {
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
stepWheel(-1);
}
});
useEffect(() => {
if (interacting.current) {
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const clamp = (n: number) => Math.min(max, Math.max(min, n));
const canUp = localValue < max;
const canDown = localValue > min;
const stepLocal = (direction: 1 | -1): number | null => {
const next = clamp(localValue + direction * stepSize);
if (next === localValue) return null;
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: number) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const parsed = Number(text);
if (Number.isFinite(parsed)) {
const next = clamp(parsed);
setLocalValue(next);
void commit(next);
} else {
setText(formatLabel(value));
}
};
return (
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
ref={inputRef}
id={id}
className="choice-stepper__input"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (canUp) {
interacting.current = true;
stepLocal(1);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (canDown) {
interacting.current = true;
stepLocal(-1);
}
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
/>
</div>
);
}
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
if ('values' in props) {
return <ChoiceStepperList {...props} />;
}
return <ChoiceStepperRange {...props} />;
}
export default ChoiceStepper;
+3 -1
View File
@@ -5,10 +5,12 @@ type LedProps = {
className?: string; className?: string;
}; };
export function Led({ status = 'disconnected', className = '' }: LedProps) { function Led({ status = 'disconnected', className = '' }: LedProps) {
return ( return (
<span className={['led', className].filter(Boolean).join(' ')}> <span className={['led', className].filter(Boolean).join(' ')}>
<span className={`led-dot led-dot--${status}`} /> <span className={`led-dot led-dot--${status}`} />
</span> </span>
); );
} }
export default Led;
+464
View File
@@ -0,0 +1,464 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
export interface LinearGraphHelpers {
toPx: (x: number, y: number) => [number, number];
toPxX: (x: number) => number;
toPxY: (y: number) => number;
toData: (px: number, py: number) => [number, number];
plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D;
}
export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle {
redraw: () => void;
getCanvas: () => HTMLCanvasElement | null;
}
export type AxisScale = 'linear' | 'log';
export interface LinearGraphProps {
xMin: number;
xMax: number;
xStep?: number;
yMin: number;
yMax: number;
yStep?: number;
/** 'log' requires xMin > 0. */
xScale?: AxisScale;
/** 'log' requires yMin > 0. */
yScale?: AxisScale;
draw?: DrawFn;
width?: number;
height?: number;
padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean;
showAxes?: boolean;
showTicks?: boolean;
xLabel?: string;
yLabel?: string;
formatX?: (v: number) => string;
formatY?: (v: number) => string;
className?: string;
style?: React.CSSProperties;
}
const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v);
if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString();
};
function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1;
const raw = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
return step * mag;
}
function range(min: number, max: number, step: number): number[] {
if (step <= 0) return [];
const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) {
out.push(Math.round(v / step) * step);
}
return out;
}
interface Tick {
value: number;
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
major: boolean;
}
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
// purely for label emphasis — grid highlighting logic only really matters for log mode.
function linearTicks(min: number, max: number, step: number): Tick[] {
return range(min, max, step).map((value) => ({ value, major: false }));
}
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
function logTicks(min: number, max: number): Tick[] {
if (min <= 0 || max <= 0 || max <= min) return [];
const startDecade = Math.floor(Math.log10(min));
const endDecade = Math.ceil(Math.log10(max));
const out: Tick[] = [];
for (let d = startDecade; d <= endDecade; d++) {
const decadeValue = Math.pow(10, d);
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
const value = decadeValue * mantissa;
if (value < min - 1e-9 || value > max + 1e-9) continue;
out.push({ value, major: mantissa === 1 });
}
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
xMax,
xStep,
yMin,
yMax,
yStep,
xScale = 'linear',
yScale = 'linear',
draw,
width,
height,
padding,
showGrid = true,
showAxes = true,
showTicks = true,
xLabel,
yLabel,
formatX = defaultFormat,
formatY = defaultFormat,
style,
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
// "container query" units, computed from the actual rendered size —
// 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two.
const cq = useMemo(() => {
const w = size.width || 1;
const h = size.height || 1;
return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 };
}, [size.width, size.height]);
const pad = useMemo(() => {
const topBase = cq.h * 3;
const rightBase = cq.w * 5;
const bottomBase = cq.h * 4;
const leftBase = cq.w * 7;
const xLabelSpace = xLabel ? cq.h * 7 : 0;
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
return {
top: padding?.top ?? topBase,
right: padding?.right ?? rightBase,
bottom: padding?.bottom ?? bottomBase + xLabelSpace,
left: padding?.left ?? leftBase + yLabelSpace,
};
}, [padding, xLabel, yLabel, cq]);
// font / stroke / tick metrics, all derived from container size
const metrics = useMemo(
() => ({
tickFont: cq.min * 3,
labelFont: cq.min * 4,
tickLen: cq.min * 1.2,
axisLineWidth: cq.min * 0.4,
yLabelOffset: cq.w * 2,
}),
[cq]
);
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
const xTicks = useMemo(
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
[xLogValid, xMin, xMax, effXStep]
);
const yTicks = useMemo(
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
[yLogValid, yMin, yMax, effYStep]
);
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width: w, height: h } = entry.contentRect;
if (w > 0 && h > 0) {
setSize({ width: width ?? w, height: height ?? h });
}
});
ro.observe(el);
return () => ro.disconnect();
}, [width, height]);
const paint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = size.width;
const cssH = size.height;
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const logXMin = xLogValid ? Math.log10(xMin) : 0;
const logXMax = xLogValid ? Math.log10(xMax) : 0;
const logYMin = yLogValid ? Math.log10(yMin) : 0;
const logYMax = yLogValid ? Math.log10(yMax) : 0;
const toPxX = (x: number) => {
if (xLogValid) {
const lx = Math.log10(Math.max(x, 1e-9));
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
}
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
};
const toPxY = (y: number) => {
if (yLogValid) {
const ly = Math.log10(Math.max(y, 1e-9));
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
}
return plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
};
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => {
const xRatio = (px - plot.x) / plot.width;
const yRatio = 1 - (py - plot.y) / plot.height;
const x = xLogValid
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
: xMin + xRatio * (xMax - xMin);
const y = yLogValid
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
: yMin + yRatio * (yMax - yMin);
return [x, y];
};
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
}
const borderInset = 1; // Avoid clipping outside graph bounds
ctx.save();
ctx.beginPath();
ctx.rect(
plot.x + borderInset,
plot.y + borderInset,
plot.width - borderInset * 2,
plot.height - borderInset * 2
);
ctx.clip();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showGrid) {
// minor grid lines first (thin), then major/decade lines drawn on top so
// they aren't obscured and read clearly as the "multiples of 10" markers.
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const t of xTicks) {
if (t.major) continue;
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of yTicks) {
if (t.major) continue;
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
const majorX = xTicks.filter((t) => t.major);
const majorY = yTicks.filter((t) => t.major);
if (majorX.length || majorY.length) {
ctx.strokeStyle = gridMajorColor;
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
ctx.beginPath();
for (const t of majorX) {
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of majorY) {
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = metrics.axisLineWidth;
ctx.beginPath();
if (!yLogValid && yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (!xLogValid && xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
ctx.stroke();
}
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showTicks) {
ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const t of xTicks) {
// In log mode, only label the decade (major) ticks — labeling every
// 2,3,4...9 mantissa gets unreadably dense.
if (xLogValid && !t.major) continue;
const px = toPxX(t.value);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const t of yTicks) {
if (yLogValid && !t.major) continue;
const py = toPxY(t.value);
ctx.beginPath();
ctx.moveTo(plot.x - metrics.tickLen, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(formatY(t.value), plot.x - metrics.tickLen - 2, py);
}
}
if (xLabel) {
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
}
if (yLabel) {
ctx.save();
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
}, [
size,
pad,
metrics,
xMin,
xMax,
xLogValid,
yMin,
yMax,
yLogValid,
xTicks,
yTicks,
showGrid,
showAxes,
showTicks,
xLabel,
yLabel,
formatX,
formatY,
draw,
]);
useEffect(() => {
paint();
}, [paint]);
useImperativeHandle(ref, () => ({
redraw: paint,
getCanvas: () => canvasRef.current,
}));
return (
<div
ref={containerRef}
className={'container'}
style={{
width: width ?? '100%',
height: height ?? '100%',
...style,
}}
>
<canvas
ref={canvasRef}
className={'canvas'}
style={{ width: size.width, height: size.height }}
/>
</div>
);
});
export default LinearGraph;
@@ -0,0 +1,54 @@
.loading-overlay {
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
background: rgba(0, 0, 0, 0.35);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--dur-base) var(--ease-standard),
visibility 0s linear var(--dur-base);
}
.loading-overlay--active {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition:
opacity var(--dur-base) var(--ease-standard),
visibility 0s linear 0s;
}
.loading-overlay__spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid var(--border-hairline);
border-top-color: var(--accent-brand);
animation: loading-overlay-spin 0.8s linear infinite;
}
.loading-overlay__label {
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
}
@keyframes loading-overlay-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,32 @@
import './LoadingOverlay.css';
function LoadingOverlay({
active,
label = 'Loading…',
overlayClassName = '',
children,
}: {
active: boolean;
label?: string;
overlayClassName?: string;
children: React.ReactNode;
}) {
return (
<>
{children}
<div
className={
`loading-overlay ${overlayClassName}` + (active ? ' loading-overlay--active' : '')
}
aria-live="polite"
aria-busy={active}
>
<div className="loading-overlay__spinner" />
<span className="loading-overlay__label">{label}</span>
</div>
</>
);
}
export default LoadingOverlay;
+114
View File
@@ -0,0 +1,114 @@
.meter-bar {
display: flex;
flex-direction: column;
align-items: left;
gap: 0.3cqh;
user-select: none;
-webkit-user-select: none;
}
.meter-bar__title {
font-size: 1.5cqh;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
text-align: left;
justify-content: left;
}
.meter-bar__body {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.3cqh;
flex: 1 1 auto;
}
.meter-bar__leds-wrap {
position: relative;
width: 3cqh;
flex: 1 1 auto;
}
/* All rows — the 2 indicator LEDs and every level segment — are direct
siblings in this one flex column with equal flex weight, so they're
guaranteed the exact same height. No row gets a hardcoded pixel height. */
.meter-bar__leds {
display: flex;
flex-direction: column;
gap: 0.5cqh;
width: 100%;
height: 100%;
}
.meter-bar__led {
flex: 1 1 0;
min-height: 0;
width: 100%;
border-radius: 0.5cqh;
box-sizing: border-box;
transition: background-color 60ms linear;
}
.meter-bar__led--indicator {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
/* Peak-hold marker: a thin line overlaid on the ladder at the peak's position,
independent of which LEDs are lit by rms. */
.meter-bar__peak-marker {
position: absolute;
left: -0.5cqh;
right: -0.5cqh;
height: 0.3cqh;
min-height: 1px;
background-color: #e5e7eb;
box-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
transform: translateY(0.25cqh);
pointer-events: none;
}
/* Scale sits in the same row as leds-wrap, same height, so ticks positioned
by percentage line up with the exact dB position on the ladder. */
.meter-bar__scale {
position: relative;
width: 3.5cqh;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.4);
}
.meter-bar__scale-tick {
position: absolute;
left: 0;
transform: translateY(50%);
white-space: nowrap;
}
.meter-bar__scale-tick--end {
color: rgba(255, 255, 255, 0.55);
}
.meter-bar__value {
padding-top: 0.5cqh;
font-size: 1.2cqh;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.7);
}
.meter-bar__indicator-row {
display: flex;
align-items: center;
gap: 0.1cqh;
flex: 1 1 0;
min-height: 0;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.meter-bar__indicator-row .meter-bar__led {
flex: 0 0 auto;
width: 3cqh;
height: 100%;
}
+165
View File
@@ -0,0 +1,165 @@
import './MeterBar.css';
export interface Meter {
rms: number;
peak: number;
clip?: boolean;
}
type MeterBarProps = {
label: string;
meter: Meter;
min?: number;
max?: number;
segments?: number;
/** Intermediate dB values to mark on the scale, e.g. [0] or [-20, -6, 0]. */
ticks?: number[];
};
function segmentColor(posRatioFromBottom: number) {
if (posRatioFromBottom > 0.9) return '#f87171'; // red — near clip
if (posRatioFromBottom > 0.7) return '#fbbf24'; // yellow — hot
return '#4ade80'; // green — normal
}
// Formats a dB value with an explicit '+' sign for positive values (e.g. +12, -60, 0)
function formatDb(v: number, decimals = 0) {
if (!Number.isFinite(v)) return '—';
const fixed = v.toFixed(decimals);
return v > 0 ? `+${fixed}` : fixed;
}
function MeterBar({
label,
meter,
min = -60,
max = +10,
segments = 7,
ticks = [0, -50, -40, -30, -20, -10],
}: MeterBarProps) {
const { rms, peak, clip } = meter;
// Maps a raw dB value to a 0..1 ratio from the bottom of the ladder, clamped to range.
const ratioOf = (v: number) => {
const clamped = Math.min(max, Math.max(min, v));
return (clamped - min) / (max - min);
};
// --- RMS ladder (drives which LEDs are lit) ---
const rmsRatio = ratioOf(rms);
const litCount = Math.round(rmsRatio * segments);
// --- Peak marker (position only, doesn't affect lit count) ---
const peakRatio = ratioOf(peak);
// --- Intermediate tick marks on the scale, e.g. a 0 dB reference line ---
const visibleTicks = ticks.filter((t) => t > min && t < max);
// --- Red "over" LED: lit whenever rms or peak crosses max ---
const isOverMax = rms >= max || peak >= max;
// --- Orange "limit" LED: lit strictly from the meter's own clip flag.
// clip is optional — some meters simply don't report a limiter/clip state.
const hasLimiter = clip !== undefined;
const isClipping = clip === true;
// The over-led and limit-led are rendered as two extra rows in the very
// same flex column as the level segments (see meter-bar__leds below), so
// they get identical row height automatically instead of a hardcoded one.
// Because of that, the ladder itself only occupies `segments / totalRows`
// of the column — the rest of the math (peak marker, scale ticks) has to
// scale down to match.
const totalRows = segments + 2;
const ladderFraction = segments / totalRows;
const toColumnPercent = (ratio: number) => ratio * ladderFraction * 100;
return (
<div className="meter-bar">
<div className="meter-bar__title">{label}</div>
<div className="meter-bar__body">
<div className="meter-bar__leds-wrap">
<div className="meter-bar__leds">
<div
className="meter-bar__indicator-row"
style={{ visibility: hasLimiter ? 'visible' : 'hidden' }}
>
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isClipping ? '#fb923c' : 'rgba(255,255,255,0.08)',
}}
title="Limit"
/>
<span>Limit</span>
</div>
<div className="meter-bar__indicator-row">
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isOverMax ? '#f87171' : 'rgba(255,255,255,0.08)',
}}
title="Clip"
/>
<span>Clip</span>
</div>
{Array.from({ length: segments }).map((_, i) => {
// i=0 renders at the top; convert to "position from bottom" for lighting + color
const posFromBottom = segments - 1 - i;
const lit = posFromBottom < litCount;
return (
<div
key={i}
className="meter-bar__led"
style={{
backgroundColor: lit
? segmentColor(posFromBottom / segments)
: 'rgba(255,255,255,0.08)',
}}
/>
);
})}
</div>
<div
className="meter-bar__peak-marker"
style={{ bottom: `${toColumnPercent(peakRatio)}%` }}
title={`Peak: ${formatDb(peak, 1)} dB`}
/>
</div>
<div className="meter-bar__scale">
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: `${ladderFraction * 100}%` }}
>
{formatDb(max)}
</span>
{visibleTicks.map((t) => (
<span
key={t}
className="meter-bar__scale-tick"
style={{ bottom: `${toColumnPercent(ratioOf(t))}%` }}
>
{formatDb(t)}
</span>
))}
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: '0%' }}
>
{formatDb(min)}
</span>
</div>
</div>
<div className="meter-bar__value">
{Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'}
</div>
</div>
);
}
export default MeterBar;
@@ -1,7 +1,7 @@
.notifications { .notifications {
position: fixed; position: fixed;
right: var(--space-5); right: var(--space-5);
bottom: var(--space-5); top: var(--space-5);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
@@ -1,4 +1,4 @@
import type { Notification } from '../../types/types'; import type { Notification } from '../../types';
import './Notifications.css'; import './Notifications.css';
type Props = { type Props = {
+365 -197
View File
@@ -1,27 +1,320 @@
/* ---------- Label ---------- */ /* ---------- Sidebar ---------- */
.sidebar-rail {
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
width: var(--sidebar-width-collapsed);
display: flex;
flex-direction: column;
background: var(--bg-void);
border-right: var(--border-width) solid var(--border-hairline);
z-index: 30;
overflow: visible;
transition: width var(--dur-base) var(--ease-standard);
user-select: none;
-webkit-user-select: none;
}
.sidebar-rail--expanded {
width: var(--sidebar-width-expanded);
}
/* ---------- Header (app name + toggle) ---------- */
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
gap: var(--space-1);
padding: var(--space-1);
height: var(--header-height);
}
.sidebar-rail--expanded .sidebar-header {
justify-content: space-between;
padding-left: var(--space-3);
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: var(--border-width-active) solid transparent;
border-radius: var(--radius-pill);
background: var(--bg-void);
color: var(--text-muted);
cursor: pointer;
padding: 0;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-toggle:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.sidebar-toggle-icon {
aspect-ratio: 1;
width: 70%;
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-toggle-icon {
transform: rotate(180deg);
}
.sidebar-app-combobox {
position: relative;
flex: 1 1 auto;
min-width: 0;
.sidebar-label {
font-size: var(--sidebar-label-size);
opacity: 0; opacity: 0;
max-width: 0; max-width: 0;
overflow: hidden; pointer-events: none;
white-space: nowrap;
transition: transition:
opacity var(--dur-fast) var(--ease-standard), opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard); max-width var(--dur-base) var(--ease-standard);
} }
.sidebar-rail--expanded .sidebar-label { .sidebar-rail--expanded .sidebar-app-combobox {
opacity: 1; opacity: 1;
max-width: 200px; max-width: 200px;
pointer-events: auto;
transition-delay: var(--dur-fast);
}
.sidebar-app-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
height: calc(var(--header-height) * 0.6);
padding: 0 var(--space-2);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: border-color var(--dur-base) var(--ease-standard);
}
.sidebar-app-trigger:hover,
.sidebar-app-trigger--open {
border-color: var(--accent-brand);
}
.sidebar-app-trigger-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-app-trigger-caret {
flex-shrink: 0;
color: var(--text-muted);
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-app-trigger--open .sidebar-app-trigger-caret {
transform: rotate(180deg);
}
.sidebar-app-input {
width: 100%;
height: calc(var(--header-height) * 0.6);
padding: 0 var(--space-2);
background: var(--bg-panel);
border: var(--border-width) solid var(--accent-brand);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
outline: none;
}
.sidebar-app-listbox {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
right: 0;
z-index: 40;
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;
}
.sidebar-app-option {
padding: var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-app-option--highlight {
background: var(--bg-raised);
}
.sidebar-app-option--selected {
color: var(--accent-brand);
font-weight: var(--font-weight-semibold);
}
.sidebar-rail--expanded .sidebar-app-select {
opacity: 1;
max-width: 200px;
padding-inline: var(--space-2);
pointer-events: auto;
transition-delay: var(--dur-fast);
}
/* ---------- Scroll ---------- */
.sidebar-scroll {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-block: var(--space-1) var(--space-3);
overflow-y: auto;
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0);
mask-image: linear-gradient(to bottom, transparent, black 0);
}
.sidebar-rail--expanded .sidebar-scroll {
align-items: stretch;
padding-inline: var(--space-2);
}
.sidebar-scroll[data-fade-top='true'] {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
black
);
mask-image: linear-gradient(to bottom, transparent, black 24px, black calc(100% - 24px), black);
}
.sidebar-scroll[data-fade-bottom='true'] {
-webkit-mask-image: linear-gradient(to bottom, black, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to bottom, black, black calc(100% - 24px), transparent);
}
.sidebar-scroll[data-fade-top='true'][data-fade-bottom='true'] {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
}
/* ---------- Item ---------- */
.sidebar-item {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sidebar-item-gap);
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: var(--border-width-active) solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-item:hover {
background: var(--bg-raised);
color: var(--text-primary);
border: var(--border-width-active) solid var(--accent-brand);
}
.sidebar-item--active {
border-color: var(--accent-brand);
color: var(--text-primary);
}
.sidebar-rail--expanded .sidebar-item {
width: 100%;
justify-content: flex-start;
padding-inline: var(--space-2);
} }
/* ---------- Icon ---------- */ /* ---------- Icon ---------- */
.sidebar-icon { .sidebar-icon {
width: var(--sidebar-icon-size, 20px); aspect-ratio: 1;
height: var(--sidebar-icon-size, 20px); height: 80%;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -34,208 +327,32 @@
.sidebar-icon svg { .sidebar-icon svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
stroke: currentColor; stroke: currentColor;
fill: none; fill: none;
} }
/* ---------- Item ---------- */ /* ---------- Label ---------- */
.sidebar-item {
width: var(--sidebar-item-size);
height: var(--sidebar-item-size);
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width) solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition:
width var(--dur-base) var(--ease-standard),
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-item {
width: calc(100% - var(--space-4));
gap: var(--sidebar-item-gap);
justify-content: flex-start;
padding-left: var(--sidebar-item-padding);
}
/* ---------- Item States ---------- */
.sidebar-item:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
.sidebar-item--active {
border-color: var(--accent-brand);
color: var(--text-primary);
}
/* ---------- Sidebar ---------- */
.sidebar-rail {
position: fixed;
top: calc(var(--header-height));
left: 0;
bottom: 0;
width: var(--sidebar-width-collapsed);
display: flex;
flex-direction: column;
background: var(--bg-void);
border-right: var(--border-width) solid var(--border-hairline);
z-index: 30;
overflow: visible;
transition: width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded {
width: var(--sidebar-width-expanded);
}
/* ---------- App name ---------- */
.sidebar-app-name {
position: absolute;
top: var(--space-3);
left: var(--space-4);
height: var(--sidebar-toggle-size);
display: flex;
align-items: center;
font-size: var(--font-size-sm, 13px);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
.sidebar-label {
font-size: var(--font-sm);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
flex: 1 1 auto;
min-width: 0;
opacity: 0; opacity: 0;
pointer-events: none; max-width: 0;
transition: opacity var(--dur-fast) var(--ease-standard);
z-index: 31;
}
.sidebar-rail--expanded .sidebar-app-name {
opacity: 1;
pointer-events: auto;
transition-delay: var(--dur-fast);
}
/* ---------- Scroll ---------- */
.sidebar-scroll {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: calc(var(--sidebar-toggle-size) + var(--space-3) * 2) 0 var(--space-3);
overflow-y: auto;
}
.sidebar-rail--expanded .sidebar-scroll {
align-items: stretch;
}
/* ---------- Toggle ---------- */
.sidebar-toggle {
position: absolute;
top: var(--space-3);
left: calc((var(--sidebar-width-collapsed) - var(--sidebar-toggle-size)) / 2);
width: var(--sidebar-toggle-size);
height: var(--sidebar-toggle-size);
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-pill);
background: var(--bg-void);
color: var(--text-muted);
cursor: pointer;
padding: 0;
box-shadow: none;
transition: transition:
left var(--dur-base) var(--ease-standard), opacity var(--dur-fast) var(--ease-standard),
background var(--dur-base) var(--ease-standard), max-width var(--dur-base) var(--ease-standard);
color var(--dur-base) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
z-index: 32;
} }
.sidebar-rail--expanded .sidebar-toggle { .sidebar-rail--expanded .sidebar-label {
left: calc(var(--sidebar-width-expanded) - var(--sidebar-toggle-size) - var(--space-3)); opacity: 1;
} max-width: 200px;
.sidebar-toggle:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.sidebar-toggle:active {
transform: scale(0.92);
}
.sidebar-rail--expanded .sidebar-toggle:active {
transform: scale(0.92);
}
.sidebar-toggle-icon {
width: 14px;
height: 14px;
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-toggle-icon {
transform: rotate(180deg);
} }
/* ---------- Content ---------- */ /* ---------- Content ---------- */
@@ -248,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;
}
+356 -28
View File
@@ -1,53 +1,381 @@
import { useEffect, useRef, useState } from 'react';
import './Sidebar.css'; import './Sidebar.css';
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon'; import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
export type SidebarItem = { export type SidebarItem<T extends string = string> = {
id: string; id: T;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
editable?: boolean;
}; };
export default function Sidebar({ export type SidebarAppOption = {
id: string;
name: string;
};
function SidebarButton<T extends string>({
item,
active,
onSelect,
onRename,
}: {
item: SidebarItem<T>;
active: boolean;
onSelect: () => void;
onRename?: (name: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(item.label);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(item.label);
}, [item.label, editing]);
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
if (!onRename) return;
setInputValue(item.label);
setEditing(true);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
setEditing(false);
const trimmed = inputValue.trim();
if (!trimmed || trimmed === item.label) {
setInputValue(item.label);
return;
}
onRename?.(trimmed);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(item.label);
setEditing(false);
}
}
if (editing) {
return (
<div className="sidebar-item sidebar-item--editing">
<span className="sidebar-icon">{item.icon}</span>
<input
ref={inputRef}
className="sidebar-item-input"
value={inputValue}
maxLength={8}
onChange={(e) => setInputValue(e.target.value.slice(0, 8))}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename channel"
/>
</div>
);
}
return (
<button
onClick={onSelect}
title={item.label}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
>
<span className="sidebar-icon">{item.icon}</span>
<span
className="sidebar-label"
onDoubleClick={onRename ? startEditing : undefined}
title={onRename ? `${item.label} (double click to rename)` : undefined}
>
{item.label}
</span>
</button>
);
}
function AppCombobox({
appOptions,
selectedAppId,
appName,
onAppChange,
onAppRename,
}: {
appOptions: SidebarAppOption[];
selectedAppId?: string;
appName: string;
onAppChange: (id: string) => void;
onAppRename: (name: string) => void;
}) {
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
const canRename = currentOption?.id !== 'f00';
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(currentOption?.name ?? appName);
const [highlight, setHighlight] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(currentOption?.name ?? appName);
}, [currentOption?.id, currentOption?.name, appName, editing]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
if (editing) commitRename();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, inputValue]);
function openList() {
setOpen(true);
const idx = appOptions.findIndex((o) => o.id === currentOption?.id);
setHighlight(idx >= 0 ? idx : 0);
}
function pick(opt: SidebarAppOption) {
if (opt.id !== selectedAppId) onAppChange?.(opt.id);
setInputValue(opt.name);
setOpen(false);
setEditing(false);
}
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
if (!canRename) return;
setInputValue((currentOption?.name ?? appName).trim());
setEditing(true);
setOpen(false);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
if (!canRename) {
setInputValue(currentOption?.name ?? appName);
setEditing(false);
return;
}
const trimmed = inputValue.trim();
setEditing(false);
if (!trimmed) {
setInputValue(currentOption?.name ?? appName);
return;
}
const matched = appOptions.find((o) => o.name === trimmed);
if (matched) {
if (matched.id !== selectedAppId) {
onAppChange(matched.id);
}
return;
}
if (trimmed !== currentOption?.name) {
onAppRename(trimmed);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (editing) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(currentOption?.name ?? appName);
setEditing(false);
}
return;
}
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
openList();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, appOptions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
pick(appOptions[highlight]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div className="sidebar-app-combobox" ref={rootRef}>
{editing ? (
<input
ref={inputRef}
className="sidebar-app-input"
value={inputValue}
maxLength={14}
onChange={(e) => setInputValue(e.target.value.slice(0, 14))}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename app"
/>
) : (
<button
type="button"
className={'sidebar-app-trigger' + (open ? ' sidebar-app-trigger--open' : '')}
onClick={() => (open ? setOpen(false) : openList())}
onDoubleClick={startEditing}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
title={canRename ? 'Double click to rename' : undefined}
>
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
</button>
)}
{open && (
<ul className="sidebar-app-listbox" role="listbox">
{appOptions.map((opt, i) => (
<li
key={opt.id}
role="option"
aria-selected={opt.id === selectedAppId}
className={
'sidebar-app-option' +
(opt.id === selectedAppId ? ' sidebar-app-option--selected' : '') +
(i === highlight ? ' sidebar-app-option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(opt)}
>
<span className="sidebar-app-option-id">{opt.id + '-'}</span>
<span>{opt.name}</span>
</li>
))}
</ul>
)}
</div>
);
}
export function Sidebar<T extends string>({
items, items,
activeId, activeId,
onSelect, onSelect,
appName, appName,
appOptions,
selectedAppId,
onAppChange,
onAppRename,
onItemRename,
expanded, expanded,
onToggle, onToggle,
}: { }: {
items: SidebarItem[]; items: SidebarItem<T>[];
activeId: string | null; activeId: T | null;
onSelect: (id: string) => void; onSelect: (id: T) => void;
appName: string; appName: string;
appOptions?: SidebarAppOption[];
selectedAppId?: string;
onAppChange: (id: string) => void;
onAppRename: (name: string) => void;
onItemRename?: (id: T, name: string) => void;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null);
const hasOptions = appOptions && appOptions.length > 0;
const updateFade = () => {
const el = scrollRef.current;
if (!el) return;
const atTop = el.scrollTop <= 1;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
el.dataset.fadeTop = String(!atTop);
el.dataset.fadeBottom = String(!atBottom && el.scrollHeight > el.clientHeight);
};
useEffect(() => {
updateFade();
const el = scrollRef.current;
el?.addEventListener('scroll', updateFade);
window.addEventListener('resize', updateFade);
return () => {
el?.removeEventListener('scroll', updateFade);
window.removeEventListener('resize', updateFade);
};
}, [items.length]);
return ( return (
<nav className={`sidebar-rail ${expanded ? 'sidebar-rail--expanded' : ''}`}> <nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}>
<span className="sidebar-app-name">{appName}</span> <div className="sidebar-header">
{hasOptions ? (
<AppCombobox
appOptions={appOptions!}
selectedAppId={selectedAppId}
appName={appName}
onAppChange={onAppChange}
onAppRename={onAppRename}
/>
) : (
<span className="sidebar-app-name">{appName}</span>
)}
<button
className="sidebar-toggle"
onClick={onToggle}
aria-label={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
aria-expanded={expanded}
>
<ChevronLeftIcon className="sidebar-toggle-icon" />
</button>
</div>
<button <div className="sidebar-scroll" ref={scrollRef}>
className="sidebar-toggle"
onClick={onToggle}
aria-label={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
aria-expanded={expanded}
>
<ChevronLeftIcon className="sidebar-toggle-icon" />
</button>
<div className="sidebar-scroll">
{items.map((item) => ( {items.map((item) => (
<button <SidebarButton
key={item.id} key={item.id}
onClick={() => onSelect(item.id)} item={item}
title={item.label} active={activeId === item.id}
aria-label={item.label} onSelect={() => onSelect(item.id)}
aria-current={activeId === item.id ? 'page' : undefined} onRename={
className={`sidebar-item ${activeId === item.id ? 'sidebar-item--active' : ''}`} item.editable && onItemRename ? (name) => onItemRename(item.id, name) : undefined
> }
<span className="sidebar-icon">{item.icon}</span> />
<span className="sidebar-label">{item.label}</span>
</button>
))} ))}
</div> </div>
</nav> </nav>
@@ -0,0 +1,33 @@
.signal-flow {
width: 100%;
height: 100%;
}
.signal-flow__svg {
width: 100%;
height: 100%;
display: block;
}
.signal-flow__chain-line {
stroke: var(--border-hairline, #333);
stroke-width: 1;
}
.signal-flow__stage {
fill: var(--bg-void);
stroke: var(--border-hairline);
stroke-width: 1;
transition: fill 0.1s ease;
}
.signal-flow__stage:hover {
fill: var(--bg-panel);
}
.signal-flow__stage-label {
font-family: var(--font-ui);
font-size: 13px;
fill: var(--text-primary, #e5e7eb);
pointer-events: none;
}
@@ -0,0 +1,410 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
import { DSP408 } from '../../types';
import {
INPUT_ORDER,
OUTPUT_ORDER,
MATRIX_GAIN_MIN,
MATRIX_GAIN_MAX,
MATRIX_GAIN_STEP,
} from '../../features/dsp408/constants';
import { SidebarId } from '../../features/dsp408/constants';
import './SignalFlowMatrix.css';
const INPUT_SIDEBAR: Record<InputChannel, SidebarId> = {
InA: 'ina',
InB: 'inb',
InC: 'inc',
InD: 'ind',
};
const OUTPUT_SIDEBAR: Record<OutputChannel, SidebarId> = {
Out1: 'out1',
Out2: 'out2',
Out3: 'out3',
Out4: 'out4',
Out5: 'out5',
Out6: 'out6',
Out7: 'out7',
Out8: 'out8',
};
const INPUT_STAGES: {
key: string;
label: string;
sidebar: SidebarId | 'input';
}[] = [
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
{ key: 'gate', label: 'GATE', sidebar: 'gate' },
{ key: 'geq', label: 'GEQ', sidebar: 'geq' },
{ key: 'peq', label: 'PEQ', sidebar: 'input' },
{ key: 'hplp', label: 'HP/LP', sidebar: 'input' },
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
];
const OUTPUT_STAGES: {
key: string;
label: string;
sidebar: SidebarId | 'output';
}[] = [
{ key: 'xover', label: 'XOVER', sidebar: 'output' },
{ key: 'peq', label: 'PEQ', sidebar: 'output' },
{ key: 'gain', label: 'GAIN', sidebar: 'gain' },
{ key: 'compressor', label: 'COMP', sidebar: 'compressor' },
{ key: 'limiter', label: 'LIMIT', sidebar: 'limiter' },
{ key: 'delay', label: 'DELAY', sidebar: 'delay' },
{ key: 'mute', label: 'MUTE', sidebar: 'output' },
];
const INPUT_COLORS = [
'var(--accent-a, #60a5fa)',
'var(--accent-b, #34d399)',
'var(--accent-c, #f59e0b)',
'var(--accent-d, #f472b6)',
];
const W = 1800;
const H = 520;
const MARGIN = 36;
const LEFT_CHAIN_W = 560;
const RIGHT_CHAIN_W = 620;
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
// user units. Much wider than the visible 1–2.5px line so it's easy to click
// or catch with a scroll wheel without needing pixel-perfect aim.
const LINE_HITBOX_WIDTH = 14;
// How long the gain tooltip stays visible after the last wheel tick, in ms.
const GAIN_TOOLTIP_HOLD_MS = 700;
function getRoutes(dsp: DSP408, output: OutputChannel) {
return dsp.state.current_config.output_states[output].matrix_routes;
}
function isRouted(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).connected.includes(input);
}
function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
return getRoutes(dsp, output).gains[input];
}
function formatGainDb(value: number) {
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)} dB`;
}
export function useNonPassiveWheel(
ref: React.RefObject<Element | null>,
handler: (e: WheelEvent) => void
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = ref.current;
if (!el) return;
const listener = (e: Event) => handlerRef.current(e as WheelEvent);
el.addEventListener('wheel', listener, { passive: false });
return () => el.removeEventListener('wheel', listener);
}, [ref]);
}
type GainTooltipState = {
visible: boolean;
x: number;
y: number;
label: string;
};
function SignalFlowMatrix({
dsp,
setMatrixRouting,
setMatrixGain,
setActiveSidebar,
onGainPreview,
}: {
dsp: DSP408;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (input: InputChannel, output: OutputChannel, gain: number) => Promise<boolean>;
setActiveSidebar: React.Dispatch<React.SetStateAction<SidebarId>>;
onGainPreview: (input: InputChannel, output: OutputChannel, value: number | null) => void;
}) {
const nInputs = INPUT_ORDER.length;
const nOutputs = OUTPUT_ORDER.length;
const usableH = H - MARGIN * 2;
const inputRowH = usableH / nInputs;
const outputRowH = usableH / nOutputs;
const inputStageW = LEFT_CHAIN_W / (INPUT_STAGES.length + 1);
const outputStageW = RIGHT_CHAIN_W / (OUTPUT_STAGES.length + 1);
const leftStart = MARGIN;
const rightStart = W - MARGIN - RIGHT_CHAIN_W;
const inputCenterY = (i: number) => MARGIN + inputRowH * (i + 0.5);
const outputCenterY = (j: number) => MARGIN + outputRowH * (j + 0.5);
const inputNodeX = leftStart + LEFT_CHAIN_W;
const outputNodeX = rightStart;
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
visible: false,
x: 0,
y: 0,
label: '',
});
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const WHEEL_COMMIT_DELAY = 250;
// key = `${input}-${output}`
const pendingGains = useRef<Map<string, number>>(new Map());
const commitTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
return () => {
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
commitTimers.current.forEach(clearTimeout);
};
}, []);
const handleToggle = (output: OutputChannel, input: InputChannel) => {
const routes = getRoutes(dsp, output);
const next = routes.connected.includes(input)
? routes.connected.filter((c) => c !== input)
: [...routes.connected, input];
void setMatrixRouting(output, next);
};
const svgRef = useRef<SVGSVGElement>(null);
const handleWheelGain = (e: WheelEvent, output: OutputChannel, input: InputChannel) => {
e.preventDefault();
const key = `${input}-${output}`;
// Base on the pending value if we're mid-wheel, otherwise the real state
const current = pendingGains.current.get(key) ?? getGain(dsp, output, input);
const delta = e.deltaY < 0 ? MATRIX_GAIN_STEP : -MATRIX_GAIN_STEP;
const next =
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
pendingGains.current.set(key, next);
onGainPreview(input, output, next);
// Debounced send: only the final value goes out once the wheel pauses
const existing = commitTimers.current.get(key);
if (existing) clearTimeout(existing);
commitTimers.current.set(
key,
setTimeout(async () => {
commitTimers.current.delete(key);
const value = pendingGains.current.get(key);
if (value === undefined) return;
await setMatrixGain(input, output, value);
// Only release if the user hasn't wheeled again while we were awaiting
if (pendingGains.current.get(key) === value) {
pendingGains.current.delete(key);
onGainPreview(input, output, null);
}
}, WHEEL_COMMIT_DELAY)
);
// Tooltip (unchanged, shows the pending value)
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
setGainTooltip({ visible: true, x: e.clientX, y: e.clientY, label: formatGainDb(next) });
hideTooltipTimer.current = setTimeout(() => {
setGainTooltip((t) => ({ ...t, visible: false }));
}, GAIN_TOOLTIP_HOLD_MS);
};
useNonPassiveWheel(svgRef, (e) => {
const hit = (e.target as Element).closest<SVGElement>('[data-wheel-route]');
if (!hit) return; // not over a connected line: let the page scroll normally
const { input, output } = hit.dataset;
if (!input || !output) return;
handleWheelGain(e, output as OutputChannel, input as InputChannel);
});
const routePairs = useMemo(() => {
const pairs: { input: InputChannel; output: OutputChannel; i: number; j: number }[] = [];
OUTPUT_ORDER.forEach((output, j) => {
INPUT_ORDER.forEach((input, i) => pairs.push({ input, output, i, j }));
});
return pairs;
}, []);
// Draw dashed (unconnected) links first so connected (solid) links always
// paint on top of them, regardless of input/output index order. This has
// to be recomputed whenever routing changes, since isRouted reads live
// dsp state — unlike routePairs above, which is just static index pairs.
const orderedRoutePairs = useMemo(() => {
return [...routePairs].sort((a, b) => {
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
return aConnected - bConnected;
});
}, [routePairs, dsp]);
return (
<div className="signal-flow">
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet"
className="signal-flow__svg"
>
{orderedRoutePairs.map(({ input, output, i, j }) => {
const connected = isRouted(dsp, output, input);
const y1 = inputCenterY(i);
const y2 = outputCenterY(j);
const cx1 = inputNodeX + (outputNodeX - inputNodeX) * 0.4;
const cx2 = inputNodeX + (outputNodeX - inputNodeX) * 0.6;
const path = `M ${inputNodeX} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${outputNodeX} ${y2}`;
const color = INPUT_COLORS[i % INPUT_COLORS.length];
return (
<g key={`${input}-${output}`}>
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
the line is easy to grab without needing to hit the thin visible
stroke exactly. Sits directly under the visible path. */}
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={LINE_HITBOX_WIDTH}
style={{ cursor: 'pointer' }}
onClick={() => handleToggle(output, input)}
data-wheel-route={connected ? '' : undefined}
data-input={input}
data-output={output}
/>
{/* Visible line, purely decorative — no pointer events, so it never
competes with the hitbox above for hover/click/wheel. */}
<path
d={path}
fill="none"
stroke={connected ? color : 'var(--border-hairline, #333)'}
strokeWidth={connected ? 2.5 : 1}
strokeDasharray={connected ? undefined : '4 5'}
opacity={connected ? 0.9 : 0.35}
pointerEvents="none"
/>
</g>
);
})}
{INPUT_ORDER.map((input, i) => {
const y = inputCenterY(i);
const boxH = Math.min(inputRowH * 0.55, 40);
const name = dsp.state.current_config.input_states[input].name;
const boxes = [{ key: 'name', label: name, sidebar: 'input' as const }, ...INPUT_STAGES];
return (
<g key={input}>
{boxes.map((stage, si) => {
const x = leftStart + si * inputStageW;
const sidebarId = stage.sidebar === 'input' ? INPUT_SIDEBAR[input] : stage.sidebar;
return (
<g
key={stage.key}
style={{ cursor: 'pointer' }}
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
>
<rect
x={x + 3}
y={y - boxH / 2}
width={inputStageW - 6}
height={boxH}
rx={6}
className="signal-flow__stage"
/>
<text
x={x + inputStageW / 2}
y={y}
textAnchor="middle"
dominantBaseline="central"
className="signal-flow__stage-label"
>
{si === 0 ? name : stage.label}
</text>
</g>
);
})}
</g>
);
})}
{OUTPUT_ORDER.map((output, j) => {
const y = outputCenterY(j);
const boxH = Math.min(outputRowH * 0.55, 32);
const name = dsp.state.current_config.output_states[output].name;
const boxes = [
...OUTPUT_STAGES,
{ key: 'name', label: name, sidebar: 'output' as const },
];
return (
<g key={output}>
{boxes.map((stage, si) => {
const x = rightStart + si * outputStageW;
const sidebarId =
stage.sidebar === 'output' ? OUTPUT_SIDEBAR[output] : stage.sidebar;
const isLast = si === boxes.length - 1;
return (
<g
key={stage.key}
style={{ cursor: 'pointer' }}
onClick={() => setActiveSidebar(sidebarId as SidebarId)}
>
<rect
x={x + 3}
y={y - boxH / 2}
width={outputStageW - 6}
height={boxH}
rx={6}
className="signal-flow__stage"
/>
<text
x={x + outputStageW / 2}
y={y}
textAnchor="middle"
dominantBaseline="central"
className="signal-flow__stage-label"
>
{isLast ? name : stage.label}
</text>
</g>
);
})}
</g>
);
})}
</svg>
{gainTooltip.visible && (
<div
className="signal-flow__gain-tooltip"
style={{
position: 'fixed',
left: gainTooltip.x + 14,
top: gainTooltip.y - 28,
pointerEvents: 'none',
zIndex: 50,
padding: '2px 8px',
borderRadius: 6,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
color: 'var(--text-primary, #fff)',
border: '1px solid var(--border-hairline, #333)',
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
whiteSpace: 'nowrap',
}}
>
{gainTooltip.label}
</div>
)}
</div>
);
}
export default SignalFlowMatrix;
-174
View File
@@ -1,174 +0,0 @@
.gauge-container {
width: 70px;
height: 260px;
padding-top: 20px;
padding-bottom: 10px;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: top center;
box-sizing: border-box;
}
.gauge-track {
position: relative;
width: 70px;
height: 210px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8px;
}
.slider {
position: relative;
width: 24px;
height: 100%;
cursor: pointer;
touch-action: none;
user-select: none;
}
.track {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2px;
height: 100%;
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.thumb {
position: absolute;
left: 50%;
width: 18px;
aspect-ratio: 1;
transform: translate(-50%, 50%);
border-radius: 50%;
background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand);
box-shadow: var(--shadow-sm);
}
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: 10px;
height: 2px;
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.left {
left: 8px;
}
.right {
right: 8px;
}
.value {
width: 100%;
margin-top: 4px;
padding-top: 2px;
padding-bottom: 2px;
padding-right: 4px;
padding-left: 4px;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
white-space: nowrap;
color: var(--accent-brand);
font-size: 16px;
font-family: var(--font-ui);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
overflow: hidden;
}
.value input {
width: auto;
min-width: 0;
height: 1.4em;
text-align: center;
background: transparent;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm);
appearance: none;
}
.value input,
.value-unit {
font-size: inherit;
line-height: 1;
font-family: inherit;
color: inherit;
}
-247
View File
@@ -1,247 +0,0 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css';
// Design size 260*70
const DESIGN_WIDTH = 70;
type VerticalSliderProps = {
min: number;
max: number;
step: number;
switchStepValue?: number;
switchStep?: number;
value?: number;
onChange?: (value: number) => void;
unit?: string;
className?: string;
style?: React.CSSProperties;
};
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
}
export default function VerticalSlider({
min,
max,
step,
switchStepValue,
switchStep,
value: controlledValue,
onChange,
unit = '',
className,
style,
}: VerticalSliderProps) {
const [internalValue, setInternalValue] = useState(min);
const value = controlledValue ?? internalValue;
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const lastValue = useRef(value);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const rafRef = useRef<number | null>(null);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
useEffect(() => {
if (keyboardEditing.current) return;
setDisplayValue(value);
dragValue.current = value;
}, [value]);
const currentStep =
switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue
? switchStep
: step;
const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0),
[step, switchStep]
);
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const snapped = Math.round(clamped / currentStep) * currentStep;
return Number(snapped.toFixed(6));
}
function updateValue(v: number) {
const next = normalizeValue(v);
dragValue.current = next;
lastValue.current = next;
if (thumbRef.current) {
const percent = (next - min) / (max - min);
thumbRef.current.style.bottom = `${percent * 100}%`;
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
setDisplayValue(dragValue.current);
rafRef.current = null;
});
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next); // updates displayValue only
}
function handleKeyUp() {
if (keyboardValue.current == null) return;
keyboardEditing.current = false;
const next = keyboardValue.current;
if (controlledValue === undefined) {
setInternalValue(next);
}
onChange?.(next);
keyboardValue.current = null;
}
function valueFromPointer(clientY: number) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = min + percent * (max - min);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
function endDrag(e: React.PointerEvent<HTMLDivElement>) {
dragging.current = false;
const finalValue = dragValue.current;
if (controlledValue === undefined) setInternalValue(finalValue);
setDisplayValue(finalValue);
onChange?.(finalValue);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) updateValue(parsed);
}
setEditingText(null);
}
const percent = (value - min) / (max - min);
return (
<div className={['gauge-container', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value" style={{ fontSize: `${valueFontSize}px` }}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div>
</div>
);
}
@@ -69,6 +69,7 @@
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
font: inherit;
background: transparent; background: transparent;
border: var(--border-width-active) solid transparent; border: var(--border-width-active) solid transparent;
@@ -133,11 +134,12 @@
gap: var(--space-3); gap: var(--space-3);
height: var(--tab-height); height: var(--tab-height);
padding: 0 var(--space-3); padding: 0 var(--space-3);
border: var(--border-width-active) solid transparent; border: var(--border-width-active) solid var(--border-hairline);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-xs);
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
scroll-snap-align: start; scroll-snap-align: start;
@@ -148,6 +150,9 @@
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch + (var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size) var(--tab-close-size)
); );
user-select: none;
-webkit-user-select: none;
} }
.tab:hover { .tab:hover {
@@ -156,15 +161,19 @@
border-color: var(--accent-brand); border-color: var(--accent-brand);
} }
.tab span[title] { .tab-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0;
user-select: none;
} }
.tab-led { .tab-led {
height: 40%; height: 50%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex: 0 0 auto; flex: 0 0 auto;
@@ -173,24 +182,33 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 0; border: 0;
min-width: 0; border-radius: var(--radius-sm);
background: transparent;
padding: 0;
} }
.tab-close { .tab-close {
height: 50%; height: 50%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 0 0 auto;
border: 0;
border-radius: var(--radius-sm);
margin-left: auto; margin-left: auto;
padding: 0; padding: 0;
font-size: var(--tab-close-font); font-size: var(--tab-close-font);
line-height: 1;
color: var(--text-muted); color: var(--text-muted);
border-radius: var(--radius-sm);
background: transparent;
} }
.tab--active { .tab--active {
@@ -1,9 +1,9 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { DSP, AppPage } from '../../types/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';
import { Led } from '../Led/Led'; import Led from '../Led/Led';
import { CloseIcon } from '../../assets/icons/CloseIcon'; import { CloseIcon } from '../../assets/icons/CloseIcon';
import './Topbar.css'; import './Topbar.css';
@@ -22,6 +22,9 @@ function Tab({
step, step,
onSelect, onSelect,
onRemove, onRemove,
onConnect,
onDisconnect,
actions,
}: { }: {
dsp: DSP; dsp: DSP;
active: boolean; active: boolean;
@@ -29,20 +32,67 @@ function Tab({
step: number; step: number;
onSelect: () => void; onSelect: () => void;
onRemove: () => void; onRemove: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
actions?: React.ReactNode;
}) { }) {
const isDspConnected = isConnected(dsp);
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
};
return ( return (
<button <div
className={'tab' + (active ? ' tab--active' : '') + (dimmed ? ' tab--dimmed' : '')} className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
.filter(Boolean)
.join(' ')}
data-step={step} data-step={step}
tabIndex={0}
role="tab"
aria-selected={active}
onClick={onSelect} onClick={onSelect}
onKeyDown={handleTabKeyDown}
> >
<span className="tab-led"> <button
<Led status={dsp.status} /> type="button"
className="tab-led"
aria-label={isDspConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
onSelect();
if (isDspConnected) {
onDisconnect(dsp);
} else {
onConnect(dsp);
}
}}
>
<Led
status={
dsp.isConnecting
? 'connecting'
: isConnected(dsp)
? 'connected'
: 'disconnected'
}
/>
</button>
<span className="tab-name" title={dsp.name}>
{dsp.name}
</span> </span>
<span title={dsp.name}>{dsp.name}</span>
<span {actions}
<button
type="button"
className="tab-close" className="tab-close"
role="button"
aria-label={`Remove ${dsp.name}`} aria-label={`Remove ${dsp.name}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -50,8 +100,8 @@ function Tab({
}} }}
> >
<CloseIcon /> <CloseIcon />
</span> </button>
</button> </div>
); );
} }
@@ -64,6 +114,8 @@ function TopBar({
onSelect, onSelect,
onRemove, onRemove,
onAddClick, onAddClick,
onConnect,
onDisconnect,
}: { }: {
dsps: DSP[]; dsps: DSP[];
selected: number | null; selected: number | null;
@@ -73,6 +125,8 @@ function TopBar({
onSelect: (id: number) => void; onSelect: (id: number) => void;
onRemove: (dsp: DSP) => void; onRemove: (dsp: DSP) => void;
onAddClick: () => void; onAddClick: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) { }) {
const hasTabs = dsps.length > 0; const hasTabs = dsps.length > 0;
const tabstripRef = useRef<HTMLDivElement>(null); const tabstripRef = useRef<HTMLDivElement>(null);
@@ -145,6 +199,8 @@ function TopBar({
dimmed={page === 'add-dsp'} dimmed={page === 'add-dsp'}
onSelect={() => onSelect(dsp.id)} onSelect={() => onSelect(dsp.id)}
onRemove={() => onRemove(dsp)} onRemove={() => onRemove(dsp)}
onConnect={onConnect}
onDisconnect={onDisconnect}
/> />
))} ))}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More