From 31f645ebb2e6e31746b8fcdb7bef17ba52c74cd2 Mon Sep 17 00:00:00 2001 From: LucasDLTG Date: Sun, 26 Jul 2026 17:56:25 +0200 Subject: [PATCH] feat: add keep alive --- src-tauri/src/commands.rs | 31 +- src-tauri/src/lib.rs | 4 +- src/App.tsx | 36 +- src/api/dsp408.ts | 25 ++ src/components/Sidebar/Sidebar.css | 129 +++++++ src/components/Sidebar/Sidebar.tsx | 191 +++++++++- .../dsp408/ChannelGroup/ChannelGroup.css | 2 +- .../dsp408/GainSection/GainSection.css | 87 +++-- .../dsp408/GainSection/GainSection.tsx | 112 +++++- .../dsp408/GateSection/GateSection.css | 118 +----- .../dsp408/GateSection/GateSection.tsx | 35 ++ .../dsp408/LinearGraph/LinearGraph.css | 93 +++++ .../dsp408/LinearGraph/LinearGraph.tsx | 346 ++++++++++++++++++ .../dsp408/Slider/VerticalSlider.css | 59 +-- .../dsp408/Slider/VerticalSlider.tsx | 57 +-- src/hooks/useDSPPolling.ts | 82 +++++ src/pages/AddDspPage/AddDspPage.tsx | 2 +- src/pages/DspPage/DspPage.tsx | 49 +++ src/types/dsp408State.ts | 4 + src/types/types.ts | 4 + src/variables.css | 4 + 21 files changed, 1207 insertions(+), 263 deletions(-) create mode 100644 src/api/dsp408.ts create mode 100644 src/components/dsp408/GateSection/GateSection.tsx create mode 100644 src/components/dsp408/LinearGraph/LinearGraph.css create mode 100644 src/components/dsp408/LinearGraph/LinearGraph.tsx create mode 100644 src/hooks/useDSPPolling.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f35df15..db57424 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -203,4 +203,33 @@ pub async fn set_channel_inverse_gain( Ok(r) }) -} \ No newline at end of file +} + +#[tauri::command] +pub async fn recall_preset( + state: State<'_, AppState>, + id: u64, + preset_index: usize, +) -> Result { + state.with_device_mut(id, |device| { + let success = device + .dsp + .recall_preset(preset_index) + .map_err(|e| e.to_string())?; + + Ok(success) + }) +} + +#[tauri::command] +pub async fn get_meter_levels( + state: State<'_, AppState>, + id: u64, +) -> Result { + state.with_device_mut(id, |device| { + device + .dsp + .get_meter_levels() + .map_err(|e| e.to_string()) + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36da333..1f02d22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,9 @@ pub fn run() { commands::get_dsp_state, commands::set_channel_gain, commands::set_mute, - commands::set_channel_inverse_gain + commands::set_channel_inverse_gain, + commands::recall_preset, + commands::get_meter_levels, ] ) .run(tauri::generate_context!()) diff --git a/src/App.tsx b/src/App.tsx index 791ee9d..9f66a64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,9 +8,10 @@ import DSPPage from './pages/DspPage/DspPage'; import AddDSPForm from './pages/AddDspPage/AddDspPage'; import { useNotifications } from './hooks/useNotifications'; import Notifications from './components/Notifications/Notifications'; -import { DSPState } from './types/dsp408State'; import CreditsPage from './pages/CreditsPage/CreditsPage'; import HomePage from './pages/HomePage/HomePage'; +import { getDSP408State, connectDSP408, disconnectDSP408 } from './api/dsp408'; +import { useDSPPolling } from './hooks/useDSPPolling'; function App() { const [dsps, setDsps] = useState([]); @@ -18,7 +19,7 @@ function App() { const [page, setPage] = useState('home'); const { notifications, notify, removeNotification, hoverNotification } = useNotifications(); - async function addDSP(dsp: Omit) { + async function addDSP(dsp: Omit) { try { const id = await invoke('create_dsp408', { ip: dsp.ip, @@ -30,6 +31,8 @@ function App() { id, ...dsp, status: 'disconnected', + state: null, + meters: null, }; setDsps((prev) => [...prev, newDsp]); @@ -69,17 +72,13 @@ function App() { setStatus(dsp.id, 'connecting'); try { - await invoke('connect_dsp408', { - id: dsp.id, - }); - - console.log('Connected'); + await connectDSP408(dsp.id); setStatus(dsp.id, 'connected'); - const state = await getDSPState(dsp); + const state = await getDSP408State(dsp.id); - console.log(state); + setDsps((prev) => prev.map((d) => (d.id === dsp.id ? { ...d, state } : d))); notify('Connected', 'success', dsp.name); } catch (err) { @@ -91,9 +90,7 @@ function App() { async function disconnectDSP(dsp: DSP) { try { - await invoke('disconnect_dsp408', { - id: dsp.id, - }); + await disconnectDSP408(dsp.id); setStatus(dsp.id, 'disconnected'); @@ -103,26 +100,13 @@ function App() { } } - async function getDSPState(dsp: DSP) { - try { - const state = await invoke('get_dsp_state', { - id: dsp.id, - }); - - return state; - } catch (err) { - notify(`Failed to get state: ${String(err)}`, 'error', dsp.name); - - return null; - } - } - function setStatus(id: number, status: DSPStatus) { setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d))); } const activeDsp = dsps.find((d) => d.id === selected) ?? null; const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; + useDSPPolling(dsps, selected, setDsps); return (
diff --git a/src/api/dsp408.ts b/src/api/dsp408.ts new file mode 100644 index 0000000..31b4ac3 --- /dev/null +++ b/src/api/dsp408.ts @@ -0,0 +1,25 @@ +import { invoke } from '@tauri-apps/api/core'; +import { DSPState, Meters } from '../types/dsp408State'; + +export const createDSP408 = (ip: string, port: number, deviceId: number) => + invoke('create_dsp408', { + ip, + port, + deviceId, + }); + +export const removeDSP408 = (id: number) => invoke('remove_dsp408', { id }); + +export const connectDSP408 = (id: number) => invoke('connect_dsp408', { id }); + +export const disconnectDSP408 = (id: number) => invoke('disconnect_dsp408', { id }); + +export const getDSP408State = (id: number) => invoke('get_dsp_state', { id }); + +export const recallPreset = (id: number, presetIndex: number) => + invoke('recall_preset', { + id, + presetIndex, + }); + +export const getDSP408MeterLevels = (id: number) => invoke('get_meter_levels', { id }); diff --git a/src/components/Sidebar/Sidebar.css b/src/components/Sidebar/Sidebar.css index 5f61ea5..b70a14f 100644 --- a/src/components/Sidebar/Sidebar.css +++ b/src/components/Sidebar/Sidebar.css @@ -264,3 +264,132 @@ .app-root--sidebar-expanded .stage--with-sidebar { margin-left: var(--sidebar-width-expanded); } + +.sidebar-app-combobox { + position: relative; + flex: 1 1 auto; + min-width: 0; + + opacity: 0; + max-width: 0; + pointer-events: none; + + transition: + opacity var(--dur-fast) var(--ease-standard), + max-width var(--dur-base) var(--ease-standard); +} + +.sidebar-rail--expanded .sidebar-app-combobox { + opacity: 1; + max-width: 200px; + pointer-events: auto; + transition-delay: var(--dur-fast); +} + +.sidebar-app-trigger { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + + height: calc(var(--header-height) * 0.6); + padding: 0 var(--space-2); + + background: var(--bg-panel); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-sm); + + color: var(--text-primary); + font-family: var(--font-ui); + font-size: var(--font-xs); + font-weight: var(--font-weight-semibold); + + cursor: pointer; + transition: border-color var(--dur-base) var(--ease-standard); +} + +.sidebar-app-trigger:hover, +.sidebar-app-trigger--open { + border-color: var(--accent-brand); +} + +.sidebar-app-trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-app-trigger-caret { + flex-shrink: 0; + color: var(--text-muted); + transition: transform var(--dur-base) var(--ease-standard); +} + +.sidebar-app-trigger--open .sidebar-app-trigger-caret { + transform: rotate(180deg); +} + +.sidebar-app-input { + width: 100%; + height: calc(var(--header-height) * 0.6); + padding: 0 var(--space-2); + + background: var(--bg-panel); + border: var(--border-width) solid var(--accent-brand); + border-radius: var(--radius-sm); + + color: var(--text-primary); + font-family: var(--font-ui); + font-size: var(--font-xs); + font-weight: var(--font-weight-semibold); + + outline: none; +} + +.sidebar-app-listbox { + position: absolute; + top: calc(100% + var(--space-1)); + left: 0; + right: 0; + z-index: 40; + + margin: 0; + padding: var(--space-1); + list-style: none; + + background: var(--bg-panel); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-sm); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + + max-height: 240px; + overflow-y: auto; +} + +.sidebar-app-option { + padding: var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-app-option--highlight { + background: var(--bg-raised); +} + +.sidebar-app-option--selected { + color: var(--accent-brand); + font-weight: var(--font-weight-semibold); +} +.sidebar-rail--expanded .sidebar-app-select { + opacity: 1; + max-width: 200px; + padding-inline: var(--space-2); + pointer-events: auto; + transition-delay: var(--dur-fast); +} diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index fa7026c..16c4aed 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import './Sidebar.css'; import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon'; @@ -8,6 +8,11 @@ export type SidebarItem = { icon: React.ReactNode; }; +export type SidebarAppOption = { + id: string; + name: string; +}; + function SidebarButton({ item, active, @@ -31,11 +36,178 @@ function SidebarButton({ ); } +function AppCombobox({ + appOptions, + selectedAppId, + appName, + onAppChange, + onAppRename, +}: { + appOptions: SidebarAppOption[]; + selectedAppId?: string; + appName: string; + onAppChange?: (id: string) => void; + onAppRename?: (name: string) => void; +}) { + const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0]; + + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(currentOption?.name ?? appName); + const [highlight, setHighlight] = useState(0); + + const rootRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!editing) setInputValue(currentOption?.name ?? appName); + }, [currentOption?.id, currentOption?.name, appName, editing]); + + // Close on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + if (editing) commitRename(); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editing, inputValue]); + + function openList() { + setOpen(true); + const idx = appOptions.findIndex((o) => o.id === currentOption?.id); + setHighlight(idx >= 0 ? idx : 0); + } + + function pick(opt: SidebarAppOption) { + if (opt.id !== selectedAppId) onAppChange?.(opt.id); + setInputValue(opt.name); + setOpen(false); + setEditing(false); + } + + function startEditing(e: React.MouseEvent) { + e.stopPropagation(); + setEditing(true); + setOpen(false); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + } + + function commitRename() { + const trimmed = inputValue.trim(); + setEditing(false); + if (!trimmed) { + setInputValue(currentOption?.name ?? appName); + return; + } + const matched = appOptions.find((o) => o.name === trimmed); + if (matched) { + if (matched.id !== selectedAppId) onAppChange?.(matched.id); + return; + } + if (trimmed !== currentOption?.name) { + onAppRename?.(trimmed); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (editing) { + if (e.key === 'Enter') inputRef.current?.blur(); + if (e.key === 'Escape') { + setInputValue(currentOption?.name ?? appName); + setEditing(false); + } + return; + } + if (!open) { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + openList(); + } + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, appOptions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + pick(appOptions[highlight]); + } else if (e.key === 'Escape') { + setOpen(false); + } + } + + return ( +
+ {editing ? ( + setInputValue(e.target.value)} + onBlur={commitRename} + onKeyDown={handleKeyDown} + aria-label="Rename app" + /> + ) : ( + + )} + + {open && ( +
    + {appOptions.map((opt, i) => ( +
  • setHighlight(i)} + onClick={() => pick(opt)} + > + {opt.name} +
  • + ))} +
+ )} +
+ ); +} + export default function Sidebar({ items, activeId, onSelect, appName, + appOptions, + selectedAppId, + onAppChange, + onAppRename, expanded, onToggle, }: { @@ -43,10 +215,15 @@ export default function Sidebar({ activeId: string | null; onSelect: (id: string) => void; appName: string; + appOptions?: SidebarAppOption[]; + selectedAppId?: string; + onAppChange?: (id: string) => void; + onAppRename?: (name: string) => void; expanded: boolean; onToggle: () => void; }) { const scrollRef = useRef(null); + const hasOptions = appOptions && appOptions.length > 0; const updateFade = () => { const el = scrollRef.current; @@ -71,7 +248,17 @@ export default function Sidebar({ return (