Merge pull request #4 from sean351/feat/vite-ui

feat: PWA web UI + LED flicker fix
main
Sean 2026-02-27 21:26:54 -05:00 committed by GitHub
commit bf034fdefe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 5041 additions and 12 deletions

3
.gitignore vendored
View File

@ -37,7 +37,8 @@ pkg/
# User config (not tracked — see contrib/config.example.json)
config.json
# IDE / editor
# Node / Vite
ui/node_modules/
.vscode/
.idea/
*.swp

View File

@ -11,6 +11,8 @@ depends=(
'python'
'python-pyserial'
'python-pulsectl'
'python-fastapi'
'python-uvicorn'
'pipewire-pulse'
'playerctl'
)
@ -37,9 +39,11 @@ package() {
python -m installer --destdir="$pkgdir" dist/*.whl
# Systemd user service
# Systemd user services
install -Dm644 contrib/turnupd.service \
"$pkgdir/usr/lib/systemd/user/turnupd.service"
install -Dm644 contrib/turnup-ui.service \
"$pkgdir/usr/lib/systemd/user/turnup-ui.service"
# License
install -Dm644 LICENSE \

18
contrib/turnup-ui.service Normal file
View File

@ -0,0 +1,18 @@
[Unit]
Description=Turn Up web UI
Documentation=https://github.com/sean351/turn-up-arch
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/turnup-ui
Restart=on-failure
RestartSec=5
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=turnup-ui
[Install]
WantedBy=default.target

View File

@ -17,12 +17,14 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest>=8.0"]
ui = ["fastapi>=0.110", "uvicorn>=0.29"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[project.scripts]
turnupd = "turnup.turnupd:main"
turnupd = "turnup.turnupd:main"
turnup-ui = "turnup.ui.server:main"
[project.urls]
Homepage = "https://github.com/sean351/turn-up-arch"

View File

@ -397,7 +397,9 @@ def main() -> None:
msg["id"], msg["action"], config, pulse
)
elif msg["type"] == "heartbeat":
send_leds(ser, all_led_colors(config, knob_norms))
new_colors = all_led_colors(config, knob_norms)
send_leds(ser, new_colors)
last_led_colors[:] = new_colors
# Check for config changes every 2 s (serial read timeout = 0.1 s).
now = time.monotonic()

View File

@ -0,0 +1 @@
# turnup.ui — web UI package

204
src/turnup/ui/server.py Normal file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
ui/server.py TurnUp web UI (FastAPI + uvicorn)
Serves a PWA at http://127.0.0.1:5173 that lets you edit
~/.config/turnup/config.toml and manage named TOML presets.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from ..config import DEFAULT_CONFIG_PATH, _XDG_CONFIG_DIR, load_config
log = logging.getLogger("turnup-ui")
PRESETS_DIR = Path(_XDG_CONFIG_DIR) / "presets"
STATIC_DIR = Path(__file__).parent / "static"
# ── TOML serializer ────────────────────────────────────────────────────────────
# tomllib (stdlib) is read-only; we write our own minimal serialiser so we
# don't need an extra runtime dep (tomli-w).
_SAFE_NAME = re.compile(r"^[A-Za-z0-9 _\-\.]+$")
def _s(v: str) -> str:
"""Quote a string value for TOML."""
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _color(c: list[int]) -> str:
return f"[{int(c[0])}, {int(c[1])}, {int(c[2])}]"
def config_to_toml(cfg: dict) -> str:
lines: list[str] = []
lines.append(f'port = {_s(cfg.get("port", "/dev/ttyACM0"))}')
lines.append(f'baud = {int(cfg.get("baud", 115200))}')
lines.append("")
leds = cfg.get("leds") or {}
lines.append("[leds]")
lines.append(f'mode = {_s(leds.get("mode", "volume"))}')
lines.append(f'low_color = {_color(leds.get("low_color", [255, 0, 0]))}')
lines.append(f'high_color = {_color(leds.get("high_color", [0, 255, 0]))}')
lines.append("")
knobs = cfg.get("knobs") or {}
for i in range(5):
knob = knobs.get(str(i))
if not knob:
continue
action = knob.get("action", "sink_volume")
lines.append(f"[knobs.{i}]")
lines.append(f'action = {_s(action)}')
if action == "group_volume":
tgts = knob.get("targets") or []
lines.append(f'targets = [{", ".join(_s(t) for t in tgts)}]')
else:
lines.append(f'target = {_s(knob.get("target", "default"))}')
# Optional per-knob LED override (written as an inline table)
knob_led = knob.get("led")
if knob_led and isinstance(knob_led, dict):
parts: list[str] = []
if "mode" in knob_led:
parts.append(f'mode = {_s(knob_led["mode"])}')
if "low_color" in knob_led:
parts.append(f'low_color = {_color(knob_led["low_color"])}')
if "high_color" in knob_led:
parts.append(f'high_color = {_color(knob_led["high_color"])}')
if parts:
lines.append(f'led = {{{", ".join(parts)}}}')
lines.append("")
buttons = cfg.get("buttons") or {}
for i in range(5):
btn = buttons.get(str(i))
if not btn:
continue
lines.append(f"[buttons.{i}]")
lines.append(f'action = {_s(btn.get("action", "mute_sink"))}')
lines.append(f'target = {_s(btn.get("target", "default"))}')
lines.append("")
return "\n".join(lines)
# ── FastAPI app ────────────────────────────────────────────────────────────────
app = FastAPI(title="TurnUp UI", docs_url=None, redoc_url=None)
# ── Config API ─────────────────────────────────────────────────────────────────
@app.get("/api/config")
def get_config() -> dict[str, Any]:
return load_config()
@app.post("/api/config")
async def save_config(request: Request) -> dict[str, bool]:
cfg = await request.json()
Path(DEFAULT_CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
Path(DEFAULT_CONFIG_PATH).write_text(config_to_toml(cfg))
return {"ok": True}
# ── Presets API ────────────────────────────────────────────────────────────────
def _preset_path(name: str) -> Path:
if not name or not _SAFE_NAME.match(name):
raise HTTPException(status_code=400, detail="Invalid preset name — use letters, numbers, spaces, hyphens, underscores, dots only")
return PRESETS_DIR / f"{name}.toml"
@app.get("/api/presets")
def list_presets() -> list[str]:
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
return sorted(p.stem for p in PRESETS_DIR.glob("*.toml"))
@app.get("/api/presets/{name}")
def get_preset(name: str) -> dict[str, Any]:
path = _preset_path(name)
if not path.exists():
raise HTTPException(status_code=404, detail="Preset not found")
return load_config(str(path))
@app.post("/api/presets/{name}/save")
async def save_preset(name: str, request: Request) -> dict[str, bool]:
path = _preset_path(name)
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
cfg = await request.json()
path.write_text(config_to_toml(cfg))
return {"ok": True}
@app.post("/api/presets/{name}/apply")
def apply_preset(name: str) -> dict[str, bool]:
path = _preset_path(name)
if not path.exists():
raise HTTPException(status_code=404, detail="Preset not found")
cfg = load_config(str(path))
Path(DEFAULT_CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
Path(DEFAULT_CONFIG_PATH).write_text(config_to_toml(cfg))
return {"ok": True}
@app.delete("/api/presets/{name}")
def delete_preset(name: str) -> dict[str, bool]:
path = _preset_path(name)
if not path.exists():
raise HTTPException(status_code=404, detail="Preset not found")
path.unlink()
return {"ok": True}
# ── Static files ───────────────────────────────────────────────────────────────
# Must come AFTER all /api/* routes so the catch-all doesn't shadow them.
# Vite outputs hashed assets under assets/ and references them as /assets/…
# so we serve everything straight off STATIC_DIR at the URL root.
@app.get("/")
def root() -> FileResponse:
return FileResponse(str(STATIC_DIR / "index.html"))
@app.get("/{filepath:path}")
def static_file(filepath: str) -> FileResponse:
path = (STATIC_DIR / filepath).resolve()
# Safety: disallow path traversal outside STATIC_DIR
try:
path.relative_to(STATIC_DIR.resolve())
except ValueError:
raise HTTPException(status_code=403)
if not path.exists() or not path.is_file():
# SPA fallback — return index.html for unknown paths
return FileResponse(str(STATIC_DIR / "index.html"))
return FileResponse(str(path))
# ── Entry point ────────────────────────────────────────────────────────────────
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s %(message)s",
)
log.info("TurnUp UI → http://127.0.0.1:5173")
uvicorn.run(app, host="127.0.0.1", port=5173, log_level="warning")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0d1117"/>
<!-- Knob body -->
<circle cx="256" cy="210" r="110" fill="#161b22" stroke="#58a6ff" stroke-width="14"/>
<!-- Knob indicator line -->
<line x1="256" y1="210" x2="256" y2="122" stroke="#58a6ff" stroke-width="12" stroke-linecap="round"/>
<!-- Center dot -->
<circle cx="256" cy="210" r="18" fill="#58a6ff"/>
<!-- 5 dots = 5 knobs/buttons -->
<circle cx="116" cy="390" r="14" fill="#3fb950"/>
<circle cx="176" cy="390" r="14" fill="#3fb950"/>
<circle cx="256" cy="390" r="14" fill="#58a6ff"/>
<circle cx="336" cy="390" r="14" fill="#3fb950"/>
<circle cx="396" cy="390" r="14" fill="#3fb950"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.json" />
<title>TurnUp</title>
<script type="module" crossorigin src="/assets/index-Dcy9vL-q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-35WG7lRs.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,17 @@
{
"name": "TurnUp",
"short_name": "TurnUp",
"description": "Configure your TurnUp knob/button mixer",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

View File

@ -0,0 +1,31 @@
// Network-first SW: static assets are cached after first fetch;
// API calls always go to the network.
const CACHE = 'turnup-v1';
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Always go network-first for API calls so the UI never serves stale data
if (e.request.url.includes('/api/')) return;
e.respondWith(
fetch(e.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE).then((c) => c.put(e.request, clone));
return response;
})
.catch(() => caches.match(e.request))
);
});

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,25 +1,32 @@
post_install() {
systemctl --global enable turnupd.service
echo "turnupd: service enabled for all users — it will start on next login."
echo "To start it now: systemctl --user start turnupd.service"
systemctl --global enable turnup-ui.service
echo "turnupd + turnup-ui: services enabled for all users — they will start on next login."
echo "To start them now:"
echo " systemctl --user start turnupd.service"
echo " systemctl --user start turnup-ui.service"
echo "UI will be available at http://127.0.0.1:5173"
}
post_upgrade() {
systemctl --global reenable turnupd.service
systemctl --global reenable turnup-ui.service
# Restart for all currently logged-in users
while read -r uid _; do
systemctl -M "${uid}@.host" --user restart turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user restart turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user restart turnup-ui.service 2>/dev/null || true
done < <(loginctl list-users --no-legend 2>/dev/null)
}
pre_remove() {
# Stop and disable for all currently logged-in users
while read -r uid _; do
systemctl -M "${uid}@.host" --user stop turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user disable turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user stop turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user disable turnupd.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user stop turnup-ui.service 2>/dev/null || true
systemctl -M "${uid}@.host" --user disable turnup-ui.service 2>/dev/null || true
done < <(loginctl list-users --no-legend 2>/dev/null)
# Remove the global enable symlink
# (/etc/systemd/user/default.target.wants/turnupd.service)
systemctl --global disable turnupd.service 2>/dev/null || true
systemctl --global disable turnupd.service 2>/dev/null || true
systemctl --global disable turnup-ui.service 2>/dev/null || true
}

24
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
ui/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
ui/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

15
ui/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.json" />
<title>TurnUp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3286
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
ui/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "turnup-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

15
ui/public/icon.svg Normal file
View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#0d1117"/>
<!-- Knob body -->
<circle cx="256" cy="210" r="110" fill="#161b22" stroke="#58a6ff" stroke-width="14"/>
<!-- Knob indicator line -->
<line x1="256" y1="210" x2="256" y2="122" stroke="#58a6ff" stroke-width="12" stroke-linecap="round"/>
<!-- Center dot -->
<circle cx="256" cy="210" r="18" fill="#58a6ff"/>
<!-- 5 dots = 5 knobs/buttons -->
<circle cx="116" cy="390" r="14" fill="#3fb950"/>
<circle cx="176" cy="390" r="14" fill="#3fb950"/>
<circle cx="256" cy="390" r="14" fill="#58a6ff"/>
<circle cx="336" cy="390" r="14" fill="#3fb950"/>
<circle cx="396" cy="390" r="14" fill="#3fb950"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

17
ui/public/manifest.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "TurnUp",
"short_name": "TurnUp",
"description": "Configure your TurnUp knob/button mixer",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

31
ui/public/sw.js Normal file
View File

@ -0,0 +1,31 @@
// Network-first SW: static assets are cached after first fetch;
// API calls always go to the network.
const CACHE = 'turnup-v1';
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Always go network-first for API calls so the UI never serves stale data
if (e.request.url.includes('/api/')) return;
e.respondWith(
fetch(e.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE).then((c) => c.put(e.request, clone));
return response;
})
.catch(() => caches.match(e.request))
);
});

1
ui/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

174
ui/src/App.tsx Normal file
View File

@ -0,0 +1,174 @@
import { useState, useEffect, useCallback } from 'react';
import type { Config, ToastItem } from './types';
import * as api from './api';
import { ToastContainer } from './components/Toast';
import { Connection } from './components/Connection';
import { GlobalLeds } from './components/GlobalLeds';
import { KnobCard } from './components/KnobCard';
import { ButtonCard } from './components/ButtonCard';
import { Presets } from './components/Presets';
const KNOB_INDICES = ['0', '1', '2', '3', '4'] as const;
const BTN_INDICES = ['0', '1', '2', '3', '4'] as const;
let toastSeq = 0;
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
const [saved, setSaved] = useState<Config | null>(null);
const [presets, setPresets] = useState<string[]>([]);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const addToast = useCallback((message: string, type: ToastItem['type'] = 'success') => {
const id = ++toastSeq;
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const loadPresets = useCallback(async () => {
try {
const names = await api.listPresets();
setPresets(names);
} catch {
// Non-fatal — presets dir may not exist yet
}
}, []);
// Initial load
useEffect(() => {
void (async () => {
try {
const cfg = await api.fetchConfig();
setConfig(cfg);
setSaved(cfg);
} catch (err) {
addToast(`Failed to load config: ${(err as Error).message}`, 'error');
} finally {
setLoading(false);
}
await loadPresets();
})();
}, [addToast, loadPresets]);
const isDirty = config !== null && JSON.stringify(config) !== JSON.stringify(saved);
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
setSaved(config);
addToast('Config saved — daemon will reload automatically');
} catch (err) {
addToast(`Save failed: ${(err as Error).message}`, 'error');
} finally {
setSaving(false);
}
};
const handleRevert = () => {
if (saved) setConfig(saved);
};
const patchConfig = useCallback(<K extends keyof Config>(key: K, value: Config[K]) => {
setConfig((prev) => prev ? { ...prev, [key]: value } : prev);
}, []);
if (loading) {
return (
<div id="loading">
<p>Connecting to daemon</p>
</div>
);
}
if (!config) {
return (
<div id="loading">
<p>Could not reach the API server. Is <code>turnup-ui</code> running?</p>
</div>
);
}
return (
<>
<header id="app-header">
<h1>TurnUp</h1>
{isDirty && <span id="dirty-badge" className="visible">unsaved changes</span>}
<button
className="btn-secondary"
onClick={handleRevert}
disabled={!isDirty}
>
Revert
</button>
<button
className="btn-primary"
onClick={() => void handleSave()}
disabled={!isDirty || saving}
>
{saving ? 'Saving…' : 'Save Config'}
</button>
</header>
<main>
<Connection
config={config}
onChange={(patch) => setConfig((prev) => prev ? { ...prev, ...patch } : prev)}
/>
<GlobalLeds
leds={config.leds}
onChange={(leds) => patchConfig('leds', leds)}
/>
<section className="card" id="knobs">
<h2>Knobs</h2>
<div className="cards-grid">
{KNOB_INDICES.map((i) => (
<KnobCard
key={i}
index={Number(i)}
knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }}
onChange={(knob) =>
patchConfig('knobs', { ...config.knobs, [i]: knob })
}
/>
))}
</div>
</section>
<section className="card" id="buttons">
<h2>Buttons</h2>
<div className="cards-grid">
{BTN_INDICES.map((i) => (
<ButtonCard
key={i}
index={Number(i)}
btn={config.buttons[i] ?? { action: 'mute_sink', target: 'default' }}
onChange={(btn) =>
patchConfig('buttons', { ...config.buttons, [i]: btn })
}
/>
))}
</div>
</section>
<Presets
presets={presets}
config={config}
onLoad={(cfg) => setConfig(cfg)}
onRefresh={loadPresets}
onToast={addToast}
/>
</main>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}

58
ui/src/api.ts Normal file
View File

@ -0,0 +1,58 @@
import type { Config } from './types';
async function checkResponse(r: Response): Promise<void> {
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(body?.detail ?? `${r.status} ${r.statusText}`);
}
}
export async function fetchConfig(): Promise<Config> {
const r = await fetch('/api/config');
await checkResponse(r);
return r.json();
}
export async function saveConfig(cfg: Config): Promise<void> {
const r = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
await checkResponse(r);
}
export async function listPresets(): Promise<string[]> {
const r = await fetch('/api/presets');
if (!r.ok) return [];
return r.json();
}
export async function fetchPreset(name: string): Promise<Config> {
const r = await fetch(`/api/presets/${encodeURIComponent(name)}`);
await checkResponse(r);
return r.json();
}
export async function savePreset(name: string, cfg: Config): Promise<void> {
const r = await fetch(`/api/presets/${encodeURIComponent(name)}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
await checkResponse(r);
}
export async function applyPreset(name: string): Promise<void> {
const r = await fetch(`/api/presets/${encodeURIComponent(name)}/apply`, {
method: 'POST',
});
await checkResponse(r);
}
export async function deletePreset(name: string): Promise<void> {
const r = await fetch(`/api/presets/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
await checkResponse(r);
}

View File

@ -0,0 +1,52 @@
import type { ButtonConfig, ButtonAction } from '../types';
const ACTIONS: { value: ButtonAction; label: string }[] = [
{ value: 'mute_sink', label: 'mute_sink — toggle output mute' },
{ value: 'mute_source', label: 'mute_source — toggle mic mute' },
{ value: 'command', label: 'command — run shell command' },
];
interface Props {
index: number;
btn: ButtonConfig;
onChange: (btn: ButtonConfig) => void;
}
export function ButtonCard({ index, btn, onChange }: Props) {
const isCmd = btn.action === 'command';
return (
<div className="button-card">
<div className="card-title">
<span className="card-index">{index}</span>
Button {index}
</div>
<div>
<label htmlFor={`btn-${index}-action`}>Action</label>
<select
id={`btn-${index}-action`}
value={btn.action}
onChange={(e) =>
onChange({ ...btn, action: e.target.value as ButtonAction })
}
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
</div>
<div>
<label htmlFor={`btn-${index}-target`}>{isCmd ? 'Command' : 'Target'}</label>
<input
type="text"
id={`btn-${index}-target`}
value={btn.target}
placeholder={isCmd ? 'playerctl play-pause' : 'default'}
onChange={(e) => onChange({ ...btn, target: e.target.value })}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { hexToRgb, rgbToHex } from '../utils';
interface Props {
id: string;
label: string;
value: [number, number, number];
onChange: (rgb: [number, number, number]) => void;
}
export function ColorPicker({ id, label, value, onChange }: Props) {
const hex = rgbToHex(value);
return (
<div>
<label htmlFor={id}>{label}</label>
<div className="color-row">
<input
type="color"
id={id}
value={hex}
onChange={(e) => onChange(hexToRgb(e.target.value))}
/>
<span className="color-hex">{hex}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import type { Config } from '../types';
interface Props {
config: Config;
onChange: (patch: Partial<Pick<Config, 'port' | 'baud'>>) => void;
}
export function Connection({ config, onChange }: Props) {
return (
<section className="card" id="connection">
<h2>Connection</h2>
<div className="fields">
<div className="field">
<label htmlFor="port">Serial port</label>
<input
type="text"
id="port"
value={config.port}
placeholder="/dev/ttyACM0"
onChange={(e) => onChange({ port: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor="baud">Baud rate</label>
<input
type="number"
id="baud"
value={config.baud}
onChange={(e) =>
onChange({ baud: parseInt(e.target.value, 10) || 115200 })
}
/>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,59 @@
import type { LedConfig, LedMode } from '../types';
import { ColorPicker } from './ColorPicker';
import { rgbToHex } from '../utils';
interface Props {
leds: LedConfig;
onChange: (leds: LedConfig) => void;
}
export function GlobalLeds({ leds, onChange }: Props) {
const update = (patch: Partial<LedConfig>) => onChange({ ...leds, ...patch });
const lowHex = rgbToHex(leds.low_color);
const highHex = rgbToHex(leds.high_color);
const previewBg =
leds.mode === 'off' ? '#000' :
leds.mode === 'static' ? highHex :
`linear-gradient(to right, ${lowHex}, ${highHex})`;
return (
<section className="card" id="global-leds">
<h2>Global LEDs</h2>
<div className="fields">
<div className="field">
<label htmlFor="led-mode">Mode</label>
<select
id="led-mode"
value={leds.mode}
onChange={(e) => update({ mode: e.target.value as LedMode })}
>
<option value="volume">volume fade low high</option>
<option value="static">static always high color</option>
<option value="off">off LEDs disabled</option>
</select>
</div>
<div className="field" style={{ opacity: leds.mode === 'volume' ? 1 : 0.4 }}>
<ColorPicker
id="led-low-color"
label="Low color (0 %)"
value={leds.low_color}
onChange={(low_color) => update({ low_color })}
/>
</div>
<div className="field">
<ColorPicker
id="led-high-color"
label="High color (100 %)"
value={leds.high_color}
onChange={(high_color) => update({ high_color })}
/>
</div>
</div>
<div className="led-preview" style={{ background: previewBg }} />
</section>
);
}

View File

@ -0,0 +1,141 @@
import { useState } from 'react';
import type { KnobConfig, KnobAction, LedConfig, LedMode } from '../types';
import { ColorPicker } from './ColorPicker';
const ACTIONS: { value: KnobAction; label: string }[] = [
{ value: 'sink_volume', label: 'sink_volume — output device' },
{ value: 'source_volume', label: 'source_volume — microphone' },
{ value: 'app_volume', label: 'app_volume — single app' },
{ value: 'group_volume', label: 'group_volume — multiple apps' },
];
const DEFAULT_LED: LedConfig = {
mode: 'volume',
low_color: [255, 0, 0],
high_color: [0, 255, 0],
};
interface Props {
index: number;
knob: KnobConfig;
onChange: (knob: KnobConfig) => void;
}
export function KnobCard({ index, knob, onChange }: Props) {
const [ledOpen, setLedOpen] = useState(!!knob.led);
const update = (patch: Partial<KnobConfig>) => onChange({ ...knob, ...patch });
const ledCfg: LedConfig = {
mode: knob.led?.mode ?? DEFAULT_LED.mode,
low_color: knob.led?.low_color ?? DEFAULT_LED.low_color,
high_color: knob.led?.high_color ?? DEFAULT_LED.high_color,
};
const updateLed = (patch: Partial<LedConfig>) =>
update({ led: { ...ledCfg, ...patch } });
const toggleLed = () => {
const next = !ledOpen;
setLedOpen(next);
if (!next) {
// Drop the led key entirely when panel is closed
const { led: _dropped, ...rest } = knob;
onChange(rest as KnobConfig);
}
};
return (
<div className="knob-card">
<div className="card-title">
<span className="card-index">{index}</span>
Knob {index}
</div>
{/* Action */}
<div>
<label htmlFor={`knob-${index}-action`}>Action</label>
<select
id={`knob-${index}-action`}
value={knob.action}
onChange={(e) => {
const action = e.target.value as KnobAction;
if (action === 'group_volume') {
update({ action, targets: knob.targets ?? [], target: undefined });
} else {
update({ action, target: knob.target ?? 'default', targets: undefined });
}
}}
>
{ACTIONS.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
</div>
{/* Target / Targets */}
{knob.action === 'group_volume' ? (
<div>
<label htmlFor={`knob-${index}-targets`}>Targets (comma-separated)</label>
<textarea
id={`knob-${index}-targets`}
value={(knob.targets ?? []).join(', ')}
placeholder="spotify, vlc, brave"
onChange={(e) =>
update({
targets: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
})
}
/>
</div>
) : (
<div>
<label htmlFor={`knob-${index}-target`}>Target</label>
<input
type="text"
id={`knob-${index}-target`}
value={knob.target ?? 'default'}
placeholder="default"
onChange={(e) => update({ target: e.target.value })}
/>
</div>
)}
{/* LED override toggle */}
<button className="led-override-toggle" onClick={toggleLed}>
<span>{ledOpen ? '▾' : '▸'}</span>
LED override
</button>
{/* LED override panel */}
{ledOpen && (
<div className="led-override-panel open">
<div>
<label htmlFor={`knob-${index}-led-mode`}>Mode</label>
<select
id={`knob-${index}-led-mode`}
value={ledCfg.mode}
onChange={(e) => updateLed({ mode: e.target.value as LedMode })}
>
<option value="volume">volume</option>
<option value="static">static</option>
<option value="off">off</option>
</select>
</div>
<ColorPicker
id={`knob-${index}-led-low`}
label="Low color"
value={ledCfg.low_color}
onChange={(low_color) => updateLed({ low_color })}
/>
<ColorPicker
id={`knob-${index}-led-high`}
label="High color"
value={ledCfg.high_color}
onChange={(high_color) => updateLed({ high_color })}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,106 @@
import { useState } from 'react';
import type { Config, ToastItem } from '../types';
import * as api from '../api';
interface Props {
presets: string[];
config: Config;
onLoad: (cfg: Config) => void;
onRefresh: () => void;
onToast: (msg: string, type?: ToastItem['type']) => void;
}
export function Presets({ presets, config, onLoad, onRefresh, onToast }: Props) {
const [name, setName] = useState('');
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) { onToast('Enter a preset name first', 'info'); return; }
try {
await api.savePreset(trimmed, config);
onToast(`Preset "${trimmed}" saved`);
setName('');
onRefresh();
} catch (err) {
onToast(`Save preset failed: ${(err as Error).message}`, 'error');
}
};
const handleLoad = async (presetName: string) => {
try {
const cfg = await api.fetchPreset(presetName);
onLoad(cfg);
onToast(`Preset "${presetName}" loaded — click Save Config to write to disk`, 'info');
} catch (err) {
onToast(`Load failed: ${(err as Error).message}`, 'error');
}
};
const handleApply = async (presetName: string) => {
try {
await api.applyPreset(presetName);
const cfg = await api.fetchConfig();
onLoad(cfg);
onToast(`Preset "${presetName}" applied — daemon will reload automatically`);
} catch (err) {
onToast(`Apply failed: ${(err as Error).message}`, 'error');
}
};
const handleDelete = async (presetName: string) => {
if (!confirm(`Delete preset "${presetName}"?`)) return;
try {
await api.deletePreset(presetName);
onToast(`Preset "${presetName}" deleted`);
onRefresh();
} catch (err) {
onToast(`Delete failed: ${(err as Error).message}`, 'error');
}
};
return (
<section className="card" id="presets">
<h2>Presets</h2>
<div className="save-row">
<div className="field">
<label htmlFor="preset-name">Preset name</label>
<input
type="text"
id="preset-name"
value={name}
placeholder="my-setup"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void handleSave()}
/>
</div>
<button className="btn-secondary" onClick={() => void handleSave()}>
Save current as preset
</button>
</div>
<div className="preset-list">
{presets.length === 0 ? (
<p className="empty">No presets saved yet.</p>
) : (
presets.map((p) => (
<div key={p} className="preset-item">
<span className="preset-name">{p}</span>
<div className="preset-actions">
<button className="btn-secondary btn-sm" onClick={() => void handleLoad(p)}>
Load
</button>
<button className="btn-primary btn-sm" onClick={() => void handleApply(p)}>
Apply
</button>
<button className="btn-danger btn-sm" onClick={() => void handleDelete(p)}>
Delete
</button>
</div>
</div>
))
)}
</div>
</section>
);
}

View File

@ -0,0 +1,32 @@
import { useEffect } from 'react';
import type { ToastItem } from '../types';
interface Props {
toasts: ToastItem[];
onRemove: (id: number) => void;
}
export function ToastContainer({ toasts, onRemove }: Props) {
return (
<div id="toast-container">
{toasts.map((t) => (
<ToastEl key={t.id} toast={t} onRemove={onRemove} />
))}
</div>
);
}
function ToastEl({
toast,
onRemove,
}: {
toast: ToastItem;
onRemove: (id: number) => void;
}) {
useEffect(() => {
const timer = setTimeout(() => onRemove(toast.id), 3100);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
return <div className={`toast ${toast.type}`}>{toast.message}</div>;
}

356
ui/src/index.css Normal file
View File

@ -0,0 +1,356 @@
/* ── Design tokens ─────────────────────────────────────────────────────────── */
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-2: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-dim: #1f3a5f;
--success: #3fb950;
--danger: #f85149;
--warning: #d29922;
--radius: 8px;
--gap: 16px;
}
/* ── Reset ─────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.6;
min-height: 100dvh;
}
/* ── Typography ────────────────────────────────────────────────────────────── */
h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
}
label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
/* ── Form controls ─────────────────────────────────────────────────────────── */
input[type="text"],
input[type="number"],
select,
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color .15s;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
}
select { cursor: pointer; }
textarea { resize: vertical; min-height: 60px; }
input[type="color"] {
width: 36px;
height: 36px;
padding: 2px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
flex-shrink: 0;
}
/* ── Buttons ───────────────────────────────────────────────────────────────── */
button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-size: 13px;
font-family: inherit;
font-weight: 500;
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
transition: background .15s, border-color .15s, opacity .15s;
white-space: nowrap;
}
button:active { opacity: .75; }
button:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) { background: #79b8ff; border-color: #79b8ff; }
.btn-secondary {
background: var(--surface-2);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn-danger {
background: transparent;
color: var(--danger);
border-color: var(--danger);
}
.btn-danger:hover:not(:disabled) { background: var(--danger); color: #fff; }
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
/* ── Layout ────────────────────────────────────────────────────────────────── */
#app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: var(--gap);
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
#app-header h1 {
font-size: 16px;
font-weight: 700;
letter-spacing: .04em;
flex: 1;
}
#dirty-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
background: var(--warning);
color: #000;
font-weight: 600;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 28px;
}
#loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
color: var(--text-muted);
font-size: 14px;
}
/* ── Cards / sections ──────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
/* ── Connection row ────────────────────────────────────────────────────────── */
#connection .fields {
display: flex;
gap: var(--gap);
flex-wrap: wrap;
}
#connection .field { flex: 1; min-width: 160px; }
/* ── Global LEDs ───────────────────────────────────────────────────────────── */
#global-leds .fields {
display: flex;
gap: var(--gap);
align-items: flex-end;
flex-wrap: wrap;
}
#global-leds .field { flex: 1; min-width: 120px; }
.color-row {
display: flex;
align-items: center;
gap: 8px;
}
.color-hex {
font-size: 12px;
color: var(--text-muted);
font-family: monospace;
}
.led-preview {
margin-top: 12px;
height: 8px;
border-radius: 4px;
}
/* ── Knobs / Buttons grids ─────────────────────────────────────────────────── */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--gap);
}
.knob-card,
.button-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.knob-card .card-title,
.button-card .card-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.card-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-dim);
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
/* ── LED override toggle ───────────────────────────────────────────────────── */
.led-override-toggle {
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
user-select: none;
background: none;
border: none;
padding: 0;
font-family: inherit;
}
.led-override-toggle:hover { color: var(--accent); }
.led-override-panel {
display: none;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.led-override-panel.open { display: flex; }
.led-override-panel .color-row { flex-wrap: wrap; gap: 6px; }
/* ── Presets ───────────────────────────────────────────────────────────────── */
#presets .save-row {
display: flex;
gap: var(--gap);
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 16px;
}
#presets .save-row .field { flex: 1; min-width: 180px; }
.preset-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.preset-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.preset-item .preset-name {
flex: 1;
font-weight: 500;
font-size: 13px;
}
.preset-item .preset-actions { display: flex; gap: 6px; }
/* ── Toast notifications ───────────────────────────────────────────────────── */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 10px 16px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
color: #fff;
animation: toast-in .2s ease, toast-out .3s ease 2.7s forwards;
pointer-events: auto;
}
.toast.success { background: var(--success); color: #000; }
.toast.error { background: var(--danger); }
.toast.info { background: var(--accent); color: #000; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes toast-out { from { opacity: 1; } to { opacity: 0; } }
/* ── Empty state ───────────────────────────────────────────────────────────── */
.empty {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
/* ── Responsive ────────────────────────────────────────────────────────────── */
@media (max-width: 540px) {
main { padding: 16px; }
#app-header { padding: 10px 16px; }
}

18
ui/src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// SW registration failure is non-fatal
});
});
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

35
ui/src/types.ts Normal file
View File

@ -0,0 +1,35 @@
export type LedMode = 'volume' | 'static' | 'off';
export type KnobAction = 'sink_volume' | 'source_volume' | 'app_volume' | 'group_volume';
export type ButtonAction = 'mute_sink' | 'mute_source' | 'command';
export interface LedConfig {
mode: LedMode;
low_color: [number, number, number];
high_color: [number, number, number];
}
export interface KnobConfig {
action: KnobAction;
target?: string;
targets?: string[];
led?: Partial<LedConfig>;
}
export interface ButtonConfig {
action: ButtonAction;
target: string;
}
export interface Config {
port: string;
baud: number;
leds: LedConfig;
knobs: Record<string, KnobConfig>;
buttons: Record<string, ButtonConfig>;
}
export interface ToastItem {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}

13
ui/src/utils.ts Normal file
View File

@ -0,0 +1,13 @@
export function rgbToHex([r, g, b]: [number, number, number]): string {
return (
'#' +
[r, g, b]
.map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0'))
.join('')
);
}
export function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}

28
ui/tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
ui/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
ui/tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
ui/vite.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../src/turnup/ui/static',
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
'/api': 'http://localhost:5173',
},
},
})