commit
bf034fdefe
|
|
@ -37,7 +37,8 @@ pkg/
|
||||||
# User config (not tracked — see contrib/config.example.json)
|
# User config (not tracked — see contrib/config.example.json)
|
||||||
config.json
|
config.json
|
||||||
|
|
||||||
# IDE / editor
|
# Node / Vite
|
||||||
|
ui/node_modules/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
|
||||||
6
PKGBUILD
6
PKGBUILD
|
|
@ -11,6 +11,8 @@ depends=(
|
||||||
'python'
|
'python'
|
||||||
'python-pyserial'
|
'python-pyserial'
|
||||||
'python-pulsectl'
|
'python-pulsectl'
|
||||||
|
'python-fastapi'
|
||||||
|
'python-uvicorn'
|
||||||
'pipewire-pulse'
|
'pipewire-pulse'
|
||||||
'playerctl'
|
'playerctl'
|
||||||
)
|
)
|
||||||
|
|
@ -37,9 +39,11 @@ package() {
|
||||||
|
|
||||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
|
|
||||||
# Systemd user service
|
# Systemd user services
|
||||||
install -Dm644 contrib/turnupd.service \
|
install -Dm644 contrib/turnupd.service \
|
||||||
"$pkgdir/usr/lib/systemd/user/turnupd.service"
|
"$pkgdir/usr/lib/systemd/user/turnupd.service"
|
||||||
|
install -Dm644 contrib/turnup-ui.service \
|
||||||
|
"$pkgdir/usr/lib/systemd/user/turnup-ui.service"
|
||||||
|
|
||||||
# License
|
# License
|
||||||
install -Dm644 LICENSE \
|
install -Dm644 LICENSE \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -17,12 +17,14 @@ dependencies = [
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=8.0"]
|
dev = ["pytest>=8.0"]
|
||||||
|
ui = ["fastapi>=0.110", "uvicorn>=0.29"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
turnupd = "turnup.turnupd:main"
|
turnupd = "turnup.turnupd:main"
|
||||||
|
turnup-ui = "turnup.ui.server:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/sean351/turn-up-arch"
|
Homepage = "https://github.com/sean351/turn-up-arch"
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,9 @@ def main() -> None:
|
||||||
msg["id"], msg["action"], config, pulse
|
msg["id"], msg["action"], config, pulse
|
||||||
)
|
)
|
||||||
elif msg["type"] == "heartbeat":
|
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).
|
# Check for config changes every 2 s (serial read timeout = 0.1 s).
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# turnup.ui — web UI package
|
||||||
|
|
@ -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
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
|
@ -1,25 +1,32 @@
|
||||||
post_install() {
|
post_install() {
|
||||||
systemctl --global enable turnupd.service
|
systemctl --global enable turnupd.service
|
||||||
echo "turnupd: service enabled for all users — it will start on next login."
|
systemctl --global enable turnup-ui.service
|
||||||
echo "To start it now: systemctl --user start turnupd.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() {
|
post_upgrade() {
|
||||||
systemctl --global reenable turnupd.service
|
systemctl --global reenable turnupd.service
|
||||||
|
systemctl --global reenable turnup-ui.service
|
||||||
# Restart for all currently logged-in users
|
# Restart for all currently logged-in users
|
||||||
while read -r uid _; do
|
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)
|
done < <(loginctl list-users --no-legend 2>/dev/null)
|
||||||
}
|
}
|
||||||
|
|
||||||
pre_remove() {
|
pre_remove() {
|
||||||
# Stop and disable for all currently logged-in users
|
# Stop and disable for all currently logged-in users
|
||||||
while read -r uid _; do
|
while read -r uid _; do
|
||||||
systemctl -M "${uid}@.host" --user stop 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 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)
|
done < <(loginctl list-users --no-legend 2>/dev/null)
|
||||||
|
|
||||||
# Remove the global enable symlink
|
systemctl --global disable turnupd.service 2>/dev/null || true
|
||||||
# (/etc/systemd/user/default.target.wants/turnupd.service)
|
systemctl --global disable turnup-ui.service 2>/dev/null || true
|
||||||
systemctl --global disable turnupd.service 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
@ -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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue