209 lines
6.0 KiB
TypeScript
209 lines
6.0 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import type { Config, AudioDevice, 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 [runningApps, setRunningApps] = useState<string[]>([]);
|
|
const [sinks, setSinks] = useState<AudioDevice[]>([]);
|
|
const [sources, setSources] = useState<AudioDevice[]>([]);
|
|
const [appsLoading, setAppsLoading] = 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
|
|
}
|
|
}, []);
|
|
|
|
const loadApps = useCallback(async () => {
|
|
setAppsLoading(true);
|
|
try {
|
|
const [apps, sinkList, sourceList] = await Promise.all([
|
|
api.fetchRunningApps(),
|
|
api.fetchSinks(),
|
|
api.fetchSources(),
|
|
]);
|
|
setRunningApps(apps);
|
|
setSinks(sinkList);
|
|
setSources(sourceList);
|
|
} finally {
|
|
setAppsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 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();
|
|
await loadApps();
|
|
})();
|
|
}, [addToast, loadPresets, loadApps]);
|
|
|
|
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
|
|
<button
|
|
className="btn-secondary btn-refresh-apps"
|
|
onClick={() => void loadApps()}
|
|
disabled={appsLoading}
|
|
title="Refresh list of running apps"
|
|
>
|
|
{appsLoading ? '…' : '↻'} Running apps
|
|
</button>
|
|
</h2>
|
|
<div className="cards-grid">
|
|
{KNOB_INDICES.map((i) => (
|
|
<KnobCard
|
|
key={i}
|
|
index={Number(i)}
|
|
knob={config.knobs[i] ?? { action: 'sink_volume', target: 'default' }}
|
|
runningApps={runningApps}
|
|
sinks={sinks}
|
|
sources={sources}
|
|
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} />
|
|
</>
|
|
);
|
|
}
|