Compare commits

..

10 Commits

33 changed files with 2768 additions and 1116 deletions

View File

@ -203,4 +203,47 @@ pub async fn set_channel_inverse_gain(
Ok(r)
})
}
#[tauri::command]
pub async fn recall_preset(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let success = device
.dsp
.recall_preset(preset_index)
.map_err(|e| e.to_string())?;
Ok(success)
})
}
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<dsp_thomann::dsp408::types::Meters, String> {
state.with_device_mut(id, |device| {
device
.dsp
.get_meter_levels()
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_current_preset_name(
state: State<'_, AppState>,
id: u64,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_current_preset_name(&name)
.map_err(|e| e.to_string())
})
}

View File

@ -16,7 +16,10 @@ 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,
commands::set_current_preset_name,
]
)
.run(tauri::generate_context!())

View File

@ -1,129 +1,25 @@
import { invoke } from '@tauri-apps/api/core';
import { useState } from 'react';
import './styles/App.css';
import { DSP, DSPStatus, AppPage } from './types/types';
import './App.css';
import { DSP, AppPage } from './types/types';
import TopBar from './components/Topbar/Topbar';
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 { useDSP408 } from './hooks/useDSP408';
function App() {
const [dsps, setDsps] = useState<DSP[]>([]);
const [selected, setSelected] = useState<number | null>(null);
const [page, setPage] = useState<AppPage>('home');
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
async function addDSP(dsp: Omit<DSP, 'id' | 'status'>) {
try {
const id = await invoke<number>('create_dsp408', {
ip: dsp.ip,
port: dsp.port,
deviceId: Number(dsp.deviceId),
});
const newDsp: DSP = {
id,
...dsp,
status: 'disconnected',
};
setDsps((prev) => [...prev, newDsp]);
setSelected(id);
setPage('view-dsp');
notify('Added successfully', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}
async function removeDSP(dsp: DSP) {
try {
let id = dsp.id;
await invoke('remove_dsp408', {
id,
});
setDsps((prev) => {
const next = prev.filter((d) => d.id !== id);
if (selected === id) {
setSelected(next.length ? next[0].id : null);
}
return next;
});
notify('Removed successfully', 'success', dsp?.name);
} catch (err) {
notify(`Remove failed: ${String(err)}`, 'error', dsp?.name);
}
}
async function connectDSP(dsp: DSP) {
setStatus(dsp.id, 'connecting');
try {
await invoke('connect_dsp408', {
id: dsp.id,
});
console.log('Connected');
setStatus(dsp.id, 'connected');
const state = await getDSPState(dsp);
console.log(state);
notify('Connected', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
setStatus(dsp.id, 'disconnected');
}
}
async function disconnectDSP(dsp: DSP) {
try {
await invoke('disconnect_dsp408', {
id: dsp.id,
});
setStatus(dsp.id, 'disconnected');
notify('Disconnected', 'success', dsp.name);
} catch (err) {
notify(`Disconnect failed: ${String(err)}`, 'error', dsp.name);
}
}
async function getDSPState(dsp: DSP) {
try {
const state = await invoke<DSPState>('get_dsp_state', {
id: dsp.id,
});
return state;
} catch (err) {
notify(`Failed to get state: ${String(err)}`, 'error', dsp.name);
return null;
}
}
function setStatus(id: number, status: DSPStatus) {
setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d)));
}
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
const dsp408 = useDSP408(dsps, setDsps, selected, setSelected, setPage, notify);
return (
<div className="app">
<Notifications
@ -144,25 +40,23 @@ function App() {
setPage('view-dsp');
}}
onRemove={removeDSP}
onRemove={dsp408.removeDSP}
onAddClick={() => {
setPage('add-dsp');
}}
onConnect={dsp408.connectDSP}
onDisconnect={dsp408.disconnectDSP}
/>
<main className="stage">
{page === 'credits' ? (
<CreditsPage />
) : page === 'add-dsp' ? (
<AddDSPForm onCancel={() => setPage('home')} onAdd={addDSP} />
<AddDSPForm onCancel={() => setPage('home')} onAdd={dsp408.addDSP} />
) : activeDsp ? (
<DSPPage
dsp={activeDsp}
onConnect={() => connectDSP(activeDsp)}
onDisconnect={() => disconnectDSP(activeDsp)}
notify={notify}
/>
<DSPPage dsp={activeDsp} dsp408={dsp408.forDSP(activeDsp)} />
) : (
<HomePage
onAddDSP={() => setPage('add-dsp')}

31
src/api/dsp408.ts Normal file
View File

@ -0,0 +1,31 @@
import { invoke } from '@tauri-apps/api/core';
import { DSPState, Meters } from '../types/dsp408State';
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
invoke<number>('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<DSPState>('get_dsp_state', { id });
export const recallPresetDSP408 = (id: number, presetIndex: number) =>
invoke<boolean>('recall_preset', {
id,
presetIndex,
});
export const DSP408SetCurrentPresetName = (id: number, name: string) =>
invoke<boolean>('set_current_preset_name', {
id,
name,
});
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });

View File

@ -36,3 +36,34 @@
border-color: var(--border-hairline);
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled),
.btn-toggle:hover:not(:disabled),
.btn-outline:hover:not(:disabled) {
background: var(--bg-raised);
color: var(--text-primary);
}
/* Active toggle states */
.btn-toggle.active-danger {
background: var(--accent-down);
border-color: var(--accent-down);
color: var(--bg-void);
}
.btn-toggle.active-warning {
background: var(--accent-connecting);
border-color: var(--accent-connecting);
color: var(--bg-void);
}
/* Optional: active state hover */
.btn-toggle.active-danger:hover:not(:disabled) {
background: var(--accent-down);
border-color: var(--accent-down);
}
.btn-toggle.active-warning:hover:not(:disabled) {
background: var(--accent-connecting);
border-color: var(--accent-connecting);
}

View File

@ -12,7 +12,7 @@
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
font-size: var(--font-sm);
display: flex;
flex-direction: column;

View File

@ -5,10 +5,12 @@ type LedProps = {
className?: string;
};
export function Led({ status = 'disconnected', className = '' }: LedProps) {
function Led({ status = 'disconnected', className = '' }: LedProps) {
return (
<span className={['led', className].filter(Boolean).join(' ')}>
<span className={`led-dot led-dot--${status}`} />
</span>
);
}
export default Led;

View File

@ -0,0 +1,54 @@
.loading-overlay {
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
background: rgba(0, 0, 0, 0.35);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--dur-base) var(--ease-standard),
visibility 0s linear var(--dur-base);
}
.loading-overlay--active {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition:
opacity var(--dur-base) var(--ease-standard),
visibility 0s linear 0s;
}
.loading-overlay__spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid var(--border-hairline);
border-top-color: var(--accent-brand);
animation: loading-overlay-spin 0.8s linear infinite;
}
.loading-overlay__label {
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
}
@keyframes loading-overlay-spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,32 @@
import './LoadingOverlay.css';
function LoadingOverlay({
active,
label = 'Loading…',
overlayClassName = '',
children,
}: {
active: boolean;
label?: string;
overlayClassName?: string;
children: React.ReactNode;
}) {
return (
<>
{children}
<div
className={
`loading-overlay ${overlayClassName}` + (active ? ' loading-overlay--active' : '')
}
aria-live="polite"
aria-busy={active}
>
<div className="loading-overlay__spinner" />
<span className="loading-overlay__label">{label}</span>
</div>
</>
);
}
export default LoadingOverlay;

View File

@ -1,27 +1,316 @@
/* ---------- Label ---------- */
/* ---------- Sidebar ---------- */
.sidebar-rail {
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
width: var(--sidebar-width-collapsed);
display: flex;
flex-direction: column;
background: var(--bg-void);
border-right: var(--border-width) solid var(--border-hairline);
z-index: 30;
overflow: visible;
transition: width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded {
width: var(--sidebar-width-expanded);
}
/* ---------- Header (app name + toggle) ---------- */
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: var(--space-1);
height: var(--header-height);
}
.sidebar-rail--expanded .sidebar-header {
justify-content: space-between;
padding-inline: var(--space-3);
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: var(--border-width-active) solid transparent;
border-radius: var(--radius-pill);
background: var(--bg-void);
color: var(--text-muted);
cursor: pointer;
padding: 0;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-toggle:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.sidebar-toggle-icon {
aspect-ratio: 1;
width: 70%;
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-toggle-icon {
transform: rotate(180deg);
}
.sidebar-app-combobox {
position: relative;
flex: 1 1 auto;
min-width: 0;
.sidebar-label {
font-size: var(--sidebar-label-size);
opacity: 0;
max-width: 0;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
transition:
opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-label {
.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);
}
/* ---------- Scroll ---------- */
.sidebar-scroll {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-block: var(--space-1) var(--space-3);
overflow-y: auto;
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0);
mask-image: linear-gradient(to bottom, transparent, black 0);
}
.sidebar-rail--expanded .sidebar-scroll {
align-items: stretch;
padding-inline: var(--space-2);
}
.sidebar-scroll[data-fade-top='true'] {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
black
);
mask-image: linear-gradient(to bottom, transparent, black 24px, black calc(100% - 24px), black);
}
.sidebar-scroll[data-fade-bottom='true'] {
-webkit-mask-image: linear-gradient(to bottom, black, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to bottom, black, black calc(100% - 24px), transparent);
}
.sidebar-scroll[data-fade-top='true'][data-fade-bottom='true'] {
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
mask-image: linear-gradient(
to bottom,
transparent,
black 24px,
black calc(100% - 24px),
transparent
);
}
/* ---------- Item ---------- */
.sidebar-item {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sidebar-item-gap);
aspect-ratio: 1;
height: calc(var(--header-height) * 0.7);
border: var(--border-width-active) solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition:
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-item:hover {
background: var(--bg-raised);
color: var(--text-primary);
border: var(--border-width-active) solid var(--accent-brand);
}
.sidebar-item--active {
border-color: var(--accent-brand);
color: var(--text-primary);
}
.sidebar-rail--expanded .sidebar-item {
width: 100%;
justify-content: flex-start;
padding-inline: var(--space-2);
}
/* ---------- Icon ---------- */
.sidebar-icon {
width: var(--sidebar-icon-size, 20px);
height: var(--sidebar-icon-size, 20px);
aspect-ratio: 1;
height: 80%;
flex-shrink: 0;
display: flex;
@ -34,208 +323,32 @@
.sidebar-icon svg {
width: 100%;
height: 100%;
display: block;
stroke: currentColor;
fill: none;
}
/* ---------- Item ---------- */
.sidebar-item {
width: var(--sidebar-item-size);
height: var(--sidebar-item-size);
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width) solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition:
width var(--dur-base) var(--ease-standard),
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
border-color var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-item {
width: calc(100% - var(--space-4));
gap: var(--sidebar-item-gap);
justify-content: flex-start;
padding-left: var(--sidebar-item-padding);
}
/* ---------- Item States ---------- */
.sidebar-item:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
.sidebar-item--active {
border-color: var(--accent-brand);
color: var(--text-primary);
}
/* ---------- Sidebar ---------- */
.sidebar-rail {
position: fixed;
top: calc(var(--header-height));
left: 0;
bottom: 0;
width: var(--sidebar-width-collapsed);
display: flex;
flex-direction: column;
background: var(--bg-void);
border-right: var(--border-width) solid var(--border-hairline);
z-index: 30;
overflow: visible;
transition: width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded {
width: var(--sidebar-width-expanded);
}
/* ---------- App name ---------- */
.sidebar-app-name {
position: absolute;
top: var(--space-3);
left: var(--space-4);
height: var(--sidebar-toggle-size);
display: flex;
align-items: center;
font-size: var(--font-size-sm, 13px);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
/* ---------- Label ---------- */
.sidebar-label {
font-size: var(--font-sm);
white-space: nowrap;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-fast) var(--ease-standard);
z-index: 31;
}
.sidebar-rail--expanded .sidebar-app-name {
opacity: 1;
pointer-events: auto;
transition-delay: var(--dur-fast);
}
/* ---------- Scroll ---------- */
.sidebar-scroll {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: calc(var(--sidebar-toggle-size) + var(--space-3) * 2) 0 var(--space-3);
overflow-y: auto;
}
.sidebar-rail--expanded .sidebar-scroll {
align-items: stretch;
}
/* ---------- Toggle ---------- */
.sidebar-toggle {
position: absolute;
top: var(--space-3);
left: calc((var(--sidebar-width-collapsed) - var(--sidebar-toggle-size)) / 2);
width: var(--sidebar-toggle-size);
height: var(--sidebar-toggle-size);
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-pill);
background: var(--bg-void);
color: var(--text-muted);
cursor: pointer;
padding: 0;
box-shadow: none;
max-width: 0;
transition:
left var(--dur-base) var(--ease-standard),
background var(--dur-base) var(--ease-standard),
color var(--dur-base) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
z-index: 32;
opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-toggle {
left: calc(var(--sidebar-width-expanded) - var(--sidebar-toggle-size) - var(--space-3));
}
.sidebar-toggle:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--accent-brand);
}
.sidebar-toggle:active {
transform: scale(0.92);
}
.sidebar-rail--expanded .sidebar-toggle:active {
transform: scale(0.92);
}
.sidebar-toggle-icon {
width: 14px;
height: 14px;
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-toggle-icon {
transform: rotate(180deg);
.sidebar-rail--expanded .sidebar-label {
opacity: 1;
max-width: 200px;
}
/* ---------- Content ---------- */

View File

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import './Sidebar.css';
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
@ -7,11 +8,225 @@ export type SidebarItem = {
icon: React.ReactNode;
};
export type SidebarAppOption = {
id: string;
name: string;
};
function SidebarButton({
item,
active,
onSelect,
}: {
item: SidebarItem;
active: boolean;
onSelect: () => void;
}) {
return (
<button
onClick={onSelect}
title={item.label}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
className={'sidebar-item' + (active ? ' sidebar-item--active' : '')}
>
<span className="sidebar-icon">{item.icon}</span>
<span className="sidebar-label">{item.label}</span>
</button>
);
}
function AppCombobox({
appOptions,
selectedAppId,
appName,
onAppChange,
onAppRename,
}: {
appOptions: SidebarAppOption[];
selectedAppId?: string;
appName: string;
onAppChange?: (id: string) => void;
onAppRename?: (name: string) => void;
}) {
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
const canRename = currentOption?.id !== 'f00';
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(currentOption?.name ?? appName);
const [highlight, setHighlight] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(currentOption?.name ?? appName);
}, [currentOption?.id, currentOption?.name, appName, editing]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
if (editing) commitRename();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, inputValue]);
function openList() {
setOpen(true);
const idx = appOptions.findIndex((o) => o.id === currentOption?.id);
setHighlight(idx >= 0 ? idx : 0);
}
function pick(opt: SidebarAppOption) {
if (opt.id !== selectedAppId) onAppChange?.(opt.id);
setInputValue(opt.name);
setOpen(false);
setEditing(false);
}
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
if (!canRename) return;
setEditing(true);
setOpen(false);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
if (!canRename) {
setInputValue(currentOption?.name ?? appName);
setEditing(false);
return;
}
const trimmed = inputValue.trim();
setEditing(false);
if (!trimmed) {
setInputValue(currentOption?.name ?? appName);
return;
}
const matched = appOptions.find((o) => o.name === trimmed);
if (matched) {
if (matched.id !== selectedAppId) {
onAppChange?.(matched.id);
}
return;
}
if (trimmed !== currentOption?.name) {
onAppRename?.(trimmed);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (editing) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(currentOption?.name ?? appName);
setEditing(false);
}
return;
}
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
openList();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, appOptions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
pick(appOptions[highlight]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div className="sidebar-app-combobox" ref={rootRef}>
{editing ? (
<input
ref={inputRef}
className="sidebar-app-input"
value={inputValue}
maxLength={14}
onChange={(e) => setInputValue(e.target.value.slice(0, 14))}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename app"
/>
) : (
<button
type="button"
className={'sidebar-app-trigger' + (open ? ' sidebar-app-trigger--open' : '')}
onClick={() => (open ? setOpen(false) : openList())}
onDoubleClick={startEditing}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
</button>
)}
{open && (
<ul className="sidebar-app-listbox" role="listbox">
{appOptions.map((opt, i) => (
<li
key={opt.id}
role="option"
aria-selected={opt.id === selectedAppId}
className={
'sidebar-app-option' +
(opt.id === selectedAppId ? ' sidebar-app-option--selected' : '') +
(i === highlight ? ' sidebar-app-option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(opt)}
>
{opt.name}
</li>
))}
</ul>
)}
</div>
);
}
export default function Sidebar({
items,
activeId,
onSelect,
appName,
appOptions,
selectedAppId,
onAppChange,
onAppRename,
expanded,
onToggle,
}: {
@ -19,35 +234,68 @@ 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<HTMLDivElement>(null);
const hasOptions = appOptions && appOptions.length > 0;
const updateFade = () => {
const el = scrollRef.current;
if (!el) return;
const atTop = el.scrollTop <= 1;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
el.dataset.fadeTop = String(!atTop);
el.dataset.fadeBottom = String(!atBottom && el.scrollHeight > el.clientHeight);
};
useEffect(() => {
updateFade();
const el = scrollRef.current;
el?.addEventListener('scroll', updateFade);
window.addEventListener('resize', updateFade);
return () => {
el?.removeEventListener('scroll', updateFade);
window.removeEventListener('resize', updateFade);
};
}, [items.length]);
return (
<nav className={`sidebar-rail ${expanded ? 'sidebar-rail--expanded' : ''}`}>
<span className="sidebar-app-name">{appName}</span>
<nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}>
<div className="sidebar-header">
{hasOptions ? (
<AppCombobox
appOptions={appOptions!}
selectedAppId={selectedAppId}
appName={appName}
onAppChange={onAppChange}
onAppRename={onAppRename}
/>
) : (
<span className="sidebar-app-name">{appName}</span>
)}
<button
className="sidebar-toggle"
onClick={onToggle}
aria-label={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
aria-expanded={expanded}
>
<ChevronLeftIcon className="sidebar-toggle-icon" />
</button>
</div>
<button
className="sidebar-toggle"
onClick={onToggle}
aria-label={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
aria-expanded={expanded}
>
<ChevronLeftIcon className="sidebar-toggle-icon" />
</button>
<div className="sidebar-scroll">
<div className="sidebar-scroll" ref={scrollRef}>
{items.map((item) => (
<button
<SidebarButton
key={item.id}
onClick={() => onSelect(item.id)}
title={item.label}
aria-label={item.label}
aria-current={activeId === item.id ? 'page' : undefined}
className={`sidebar-item ${activeId === item.id ? 'sidebar-item--active' : ''}`}
>
<span className="sidebar-icon">{item.icon}</span>
<span className="sidebar-label">{item.label}</span>
</button>
item={item}
active={activeId === item.id}
onSelect={() => onSelect(item.id)}
/>
))}
</div>
</nav>

View File

@ -1,247 +0,0 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css';
// Design size 260*70
const DESIGN_WIDTH = 70;
type VerticalSliderProps = {
min: number;
max: number;
step: number;
switchStepValue?: number;
switchStep?: number;
value?: number;
onChange?: (value: number) => void;
unit?: string;
className?: string;
style?: React.CSSProperties;
};
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
}
export default function VerticalSlider({
min,
max,
step,
switchStepValue,
switchStep,
value: controlledValue,
onChange,
unit = '',
className,
style,
}: VerticalSliderProps) {
const [internalValue, setInternalValue] = useState(min);
const value = controlledValue ?? internalValue;
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const lastValue = useRef(value);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const rafRef = useRef<number | null>(null);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
useEffect(() => {
if (keyboardEditing.current) return;
setDisplayValue(value);
dragValue.current = value;
}, [value]);
const currentStep =
switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue
? switchStep
: step;
const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0),
[step, switchStep]
);
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const snapped = Math.round(clamped / currentStep) * currentStep;
return Number(snapped.toFixed(6));
}
function updateValue(v: number) {
const next = normalizeValue(v);
dragValue.current = next;
lastValue.current = next;
if (thumbRef.current) {
const percent = (next - min) / (max - min);
thumbRef.current.style.bottom = `${percent * 100}%`;
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
setDisplayValue(dragValue.current);
rafRef.current = null;
});
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next); // updates displayValue only
}
function handleKeyUp() {
if (keyboardValue.current == null) return;
keyboardEditing.current = false;
const next = keyboardValue.current;
if (controlledValue === undefined) {
setInternalValue(next);
}
onChange?.(next);
keyboardValue.current = null;
}
function valueFromPointer(clientY: number) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = min + percent * (max - min);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
function endDrag(e: React.PointerEvent<HTMLDivElement>) {
dragging.current = false;
const finalValue = dragValue.current;
if (controlledValue === undefined) setInternalValue(finalValue);
setDisplayValue(finalValue);
onChange?.(finalValue);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) updateValue(parsed);
}
setEditingText(null);
}
const percent = (value - min) / (max - min);
return (
<div className={['gauge-container', className].filter(Boolean).join(' ')} style={style}>
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value" style={{ fontSize: `${valueFontSize}px` }}>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div>
</div>
);
}

View File

@ -1,32 +1,30 @@
.channel-group {
position: relative;
height: calc(100% - var(--space-1) - var(--space-1));
flex: 0 0 auto;
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: flex-end;
border: 1px solid var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
/* enough top padding to fully clear the label's own height, not just its border-overlap */
padding: calc(1.2vh + var(--space-3)) var(--space-3) 0;
margin-bottom: var(--space-1);
margin-top: var(--space-1);
padding: 6cqh var(--space-3) 1cqh;
margin-bottom: 1cqh;
margin-top: 1cqh;
box-sizing: border-box;
overflow: visible; /* in case something upstream set hidden */
}
.channel-group__label {
position: absolute;
top: var(--space-2); /* sits inside the padded space, not straddling the border */
top: 1cqh;
left: 50%;
transform: translateX(-50%); /* only horizontal centering now — no vertical overlap */
transform: translateX(-50%);
padding: 0 var(--space-2);
font-family: var(--font-ui);
font-size: var(--tab-close-font);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
@ -39,5 +37,5 @@
flex-direction: row;
align-items: stretch;
height: 100%;
gap: var(--space-4);
}
gap: var(--space-1);
}

View File

@ -1,64 +1,107 @@
/* .connection-panel-frame {
width: 33.333%;
height: 33.333%;
box-sizing: border-box;
}
.panel {
.connection-section {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
position: relative;
justify-content: center;
align-items: center;
}
.panel-type {
.connection-panel {
width: 340px;
display: flex;
flex-direction: column;
}
.connection-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
min-width: 0;
}
.connection-panel__header-text {
flex: 1;
min-width: 0;
}
.connection-panel__title {
margin: 0;
font-size: var(--font-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-panel__type {
margin-top: var(--space-1);
font-size: 12px;
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.panel-header {
.connection-panel__specs {
margin: var(--space-5) 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.connection-panel__spec {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.panel-header-text {
min-width: 0;
flex: 1 1 auto;
.connection-panel__spec dt {
color: var(--text-muted);
font-size: var(--font-sm);
}
.panel-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.connection-panel__spec dd {
margin: 0;
font-size: 18px;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.mono {
.connection-panel__mono {
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.specs {
.connection-panel__actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
}
.connection-panel__status {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.connection-section {
position: absolute;
inset: 0;
.connection-panel__led {
height: 14px;
aspect-ratio: 1 / 1;
display: flex;
justify-content: center;
align-items: center;
} */
justify-content: center;
flex: 0 0 auto;
}

View File

@ -1,14 +1,10 @@
// ConnectionPanel.tsx
import { DSP } from '../../../types/types';
import Button from '../../Button/Button';
import Card from '../../Card/Card';
import Led from '../../Led/Led';
import './ConnectionSection.css';
const DESIGN_WIDTH = 340;
const DESIGN_HEIGHT = 280;
const BTN_W = 100;
const BTN_H = 40;
function ConnectionPanel({
dsp,
onConnect,
@ -27,59 +23,58 @@ function ConnectionPanel({
return (
<div className="connection-section">
<div
className="connection-panel-frame"
style={{ '--panel-w': DESIGN_WIDTH, '--panel-h': DESIGN_HEIGHT } as React.CSSProperties}
>
<div className="card panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="panel-header">
<div className="panel-header-text">
<h1 className="panel-title" title={dsp.name}>
{dsp.name}
</h1>
<div className="panel-type">{dsp.type}</div>
</div>
<Card className="connection-panel">
<div className="connection-panel__header">
<div className="connection-panel__header-text">
<h1 className="connection-panel__title" title={dsp.name}>
{dsp.name}
</h1>
<div className="status-block">
<span className={`led led--lg led--${dsp.status}`} />
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
</div>
<div className="connection-panel__type">{dsp.type}</div>
</div>
<dl className="specs">
<div className="spec">
<dt>IP address</dt>
<dd className="mono" title={dsp.ip}>
{dsp.ip}
</dd>
</div>
<div className="connection-panel__status">
<span className="connection-panel__led">
<Led status={dsp.status} />
</span>
<div className="spec">
<dt>Port</dt>
<dd className="mono">{dsp.port}</dd>
</div>
<div className="spec">
<dt>Device ID</dt>
<dd className="mono" title={dsp.deviceId || undefined}>
{dsp.deviceId || '—'}
</dd>
</div>
</dl>
<div className="panel-actions">
{dsp.status === 'connected' ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
</Button>
)}
<span className={`status-label status-label--${dsp.status}`}>{statusLabel}</span>
</div>
</div>
</div>
<dl className="connection-panel__specs">
<div className="connection-panel__spec">
<dt>IP address</dt>
<dd className="connection-panel__mono" title={dsp.ip}>
{dsp.ip}
</dd>
</div>
<div className="connection-panel__spec">
<dt>Port</dt>
<dd className="connection-panel__mono">{dsp.port}</dd>
</div>
<div className="connection-panel__spec">
<dt>Device ID</dt>
<dd className="connection-panel__mono" title={String(dsp.deviceId) || undefined}>
{dsp.deviceId || '—'}
</dd>
</div>
</dl>
<div className="connection-panel__actions">
{dsp.status === 'connected' ? (
<Button variant="outline" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button variant="primary" onClick={onConnect} disabled={dsp.status === 'connecting'}>
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
</Button>
)}
</div>
</Card>
</div>
);
}

View File

@ -1,82 +1,86 @@
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: var(--space-2);
background: var(--bg-void);
padding-bottom: var(--space-2);
}
.gain-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
/* Container */
.gain-section {
position: absolute; /* was: fixed — now relative to .dsp-content, which is the fixed+inset parent */
left: 0; /* was: var(--sidebar-width-collapsed) — .dsp-content already excludes the sidebar */
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--gain-section-height);
min-height: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-panel);
z-index: 20;
border-top: 1px solid var(--border-hairline);
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center; /* centers when it fits, falls back to start-aligned scroll when it overflows */
gap: var(--space-4);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
.gain-panel-frame {
flex: 0 0 auto;
height: calc(100% - 8px);
aspect-ratio: var(--panel-w) / var(--panel-h); /* now driven by JS, not duplicated */
margin: 4px 0;
box-sizing: border-box;
}
.gain-panels::-webkit-scrollbar {
height: 6px;
}
.gain-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gain-panels::-webkit-scrollbar-track {
background: transparent;
}
.gain-panel {
/* width/height now set inline from DESIGN_WIDTH/HEIGHT, drop the fixed px here */
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md);
gap: var(--space-2);
overflow-x: auto;
}
/* --- graph strip, sits above the gain section ---
Requires the shared parent (whatever renders <GainSection />) to be
position: relative — it already must be, for .gain-section's absolute
bottom:0 to anchor correctly. */
.linear-graph-section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: var(--dsp-control-heigth);
box-sizing: border-box;
padding: 8px;
background: var(--bg-void);
}
.gain-panel__title {
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 12px;
font-weight: var(--font-weight-semibold);
padding-bottom: 6px;
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-4);
background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */
}

View File

@ -1,135 +1,112 @@
import VerticalSlider from '../../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import VerticalSlider from '../Slider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import { useState } from 'react';
import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph';
import { useEffect, useRef, useState } from 'react';
import './GainSection.css';
const DESIGN_WIDTH = 90;
const DESIGN_HEIGHT = 380;
const PANEL_DESIGN_WIDTH = 90;
const PANEL_DESIGN_HEIGHT = 400;
function GainPanel({
dsp,
notify,
channel,
title,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
notify: (message: string, type?: NotificationType, dspName?: string) => void;
channel: Channel;
title: string;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const setGain = async (value: number) => {
try {
const success = await invoke<boolean>('set_channel_gain', {
id: dsp.id,
channel,
db: value,
});
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
if (success) {
notify(`Input A gain set to ${value} dB`, 'success', dsp.name);
} else {
notify('Failed to set input gain', 'error', dsp.name);
}
} catch (err) {
notify(`Gain update failed: ${String(err)}`, 'error', dsp.name);
}
};
const muted = channelState?.mute ?? false;
const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const setMute = async (muted: boolean) => {
try {
const success = await invoke<boolean>('set_mute', {
id: dsp.id,
channel,
muted,
});
if (success) {
notify(`Input A ${muted ? 'muted' : 'unmuted'}`, 'success', dsp.name);
} else {
notify('Failed to change mute state', 'error', dsp.name);
}
} catch (err) {
notify(`Mute update failed: ${String(err)}`, 'error', dsp.name);
}
};
const setInverse = async (inverted: boolean) => {
try {
const success = await invoke<boolean>('set_channel_inverse_gain', {
id: dsp.id,
channel,
inverted,
});
if (success) {
notify(`Input A phase ${inverted ? 'inverted' : 'normal'}`, 'success', dsp.name);
} else {
notify('Failed to change phase', 'error', dsp.name);
}
} catch (err) {
notify(`Phase update failed: ${String(err)}`, 'error', dsp.name);
}
};
const [muted, setMuted] = useState(false);
const [inverted, setInverted] = useState(false);
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => {
const next = !muted;
setMuted(next);
await setMute(next);
await setMute(channel, !muted);
};
const toggleInverse = async () => {
const next = !inverted;
setInverted(next);
await setInverse(next);
await setInverse(channel, !inverted);
};
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div className="gain-panel-frame">
<AutoScale designWidth={DESIGN_WIDTH} designHeight={DESIGN_HEIGHT}>
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
<div className="gain-panel__title">{title}</div>
<div className="gain-panel-viewport" ref={viewportRef}>
<div
className="gain-panel"
style={{
width: PANEL_DESIGN_WIDTH,
height: PANEL_DESIGN_HEIGHT,
transform: `translate(-50%, -50%) scale(${scale})`,
}}
>
<div className="gain-panel__title">{title}</div>
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
onChange={setGain}
/>
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
/>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
{muted ? 'Muted' : 'Mute'}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</AutoScale>
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute} style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}>
{muted ? 'Muted' : 'Mute '}
</Button>
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</div>
);
}
function GainSection({
dsp,
notify,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
notify: (message: string, type?: NotificationType, dspName?: string) => void;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const inputs: { channel: Channel; title: string }[] = [
{ channel: { Input: InputChannel.InA }, title: 'In A' },
@ -150,21 +127,64 @@ function GainSection({
];
return (
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<>
<div className="linear-graph-section">
<LinearGraph
xMin={20}
xMax={20000}
yMin={-24}
yMax={24}
yStep={6}
xLabel="Hz"
yLabel="dB"
draw={({ ctx, toPx }) => {
// placeholder flat 0 dB response — swap for real EQ/filter data.
// NOTE: this maps x linearly; for a real frequency response you'll
// likely want a log-frequency x-axis instead (see chat notes).
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div>
<div className="gain-section">
<div className="gain-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel
key={title}
dsp={dsp}
channel={channel}
title={title}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel
key={title}
dsp={dsp}
channel={channel}
title={title}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}

View File

@ -1,118 +1,4 @@
.gate-section {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--gain-section-height);
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-panel);
z-index: 20;
border-top: 1px solid var(--border-hairline);
}
.gate-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center;
gap: var(--space-4);
width: 100%;
.rerere {
height: 100%;
min-width: 0;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
.gate-panel-frame {
flex: 0 0 auto;
height: calc(100% - 8px);
aspect-ratio: var(--panel-w) / var(--panel-h);
margin: 4px 0;
box-sizing: border-box;
}
.gate-panels::-webkit-scrollbar {
height: 6px;
}
.gate-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gate-panels::-webkit-scrollbar-track {
background: transparent;
}
.gate-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
padding: 8px;
background: var(--bg-void);
}
.gate-panel__title {
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 12px;
font-weight: var(--font-weight-semibold);
padding-bottom: 6px;
border-bottom: 1px solid var(--border-hairline);
}
.gate-panel__sliders {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: var(--space-2);
width: 100%;
flex: 1;
min-height: 0;
}
.gate-panel__slider-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 0;
min-width: 0;
}
.gate-panel__slider-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: var(--font-weight-medium);
text-align: center;
padding-top: 4px;
color: var(--text-secondary);
}
/* GateSection.css */
.gate-panels .channel-group__items {
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(2, 1fr);
gap: var(--space-2);
align-items: stretch;
width: 10%;
}

View File

@ -0,0 +1,31 @@
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import './GateSection.css';
function GateSection({
dsp,
}: {
dsp: DSP;
}) {
return (
<div className="rerere">
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
value={0}
style={{ width: 70, height: 250, flex: '0 0 auto' }}
/>
</div>
);
}
export default GateSection;

View File

@ -0,0 +1,89 @@
.gain-section {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
height: 100%;
display: flex;
gap: var(--space-4);
overflow-x: auto;
}
.gain-panel-frame {
flex: 0 0 auto;
height: calc(100% - 2 * var(--space-1));
margin: var(--space-1) 0;
}
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: var(--space-2);
background: var(--bg-void);
}
.gain-panel__title {
flex: 0 0 auto; /* NEW: never let this shrink */
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
/* --- graph strip, sits above the gain section ---
Requires the shared parent (whatever renders <GainSection />) to be
position: relative — it already must be, for .gain-section's absolute
bottom:0 to anchor correctly. */
.linear-graph-section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: var(--dsp-control-heigth);
box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */
}

View File

@ -0,0 +1,346 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import styles from './LinearGraph.css';
/**
* Coordinate-space helpers passed into your draw callback.
* Everything here works in DATA units (e.g. seconds, Hz, volts) —
* you never have to think about pixels or devicePixelRatio.
*/
export interface LinearGraphHelpers {
/** data (x, y) -> canvas pixel (px, py) */
toPx: (x: number, y: number) => [number, number];
/** data x -> pixel x */
toPxX: (x: number) => number;
/** data y -> pixel y */
toPxY: (y: number) => number;
/** pixel (px, py) -> data (x, y). Useful for handling clicks/drags. */
toData: (px: number, py: number) => [number, number];
/** size of the plot area in CSS pixels (inside the axes/padding) */
plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D;
}
export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle {
/** Force a redraw (e.g. after mutating a ref-held buffer without a re-render) */
redraw: () => void;
/** Access the raw canvas, e.g. for exporting a PNG */
getCanvas: () => HTMLCanvasElement | null;
}
export interface LinearGraphProps {
xMin: number;
xMax: number;
/** grid + tick spacing on x. Omit to auto-pick a "nice" step. */
xStep?: number;
yMin: number;
yMax: number;
yStep?: number;
/** Your drawing code. Called every time the graph needs to repaint. */
draw?: DrawFn;
width?: number;
height?: number;
/** padding (px) reserved for axis labels, in CSS pixels */
padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean;
showAxes?: boolean;
showTicks?: boolean;
xLabel?: string;
yLabel?: string;
/** format a tick value, e.g. (v) => v.toFixed(1) or Hz->kHz */
formatX?: (v: number) => string;
formatY?: (v: number) => string;
className?: string;
style?: React.CSSProperties;
}
const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v);
if (abs >= 1000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString();
};
/** Pick a "nice" step (1/2/5 * 10^n) that yields ~targetTicks divisions */
function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1;
const raw = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
return step * mag;
}
function range(min: number, max: number, step: number): number[] {
if (step <= 0) return [];
const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) {
// snap to avoid float drift like 0.30000000000000004
out.push(Math.round(v / step) * step);
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
xMax,
xStep,
yMin,
yMax,
yStep,
draw,
width,
height,
padding,
showGrid = true,
showAxes = true,
showTicks = true,
xLabel,
yLabel,
formatX = defaultFormat,
formatY = defaultFormat,
className,
style,
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
const pad = useMemo(
() => ({
top: padding?.top ?? 16,
right: padding?.right ?? 16,
bottom: padding?.bottom ?? (xLabel ? 44 : 28),
left: padding?.left ?? (yLabel ? 56 : 44),
}),
[padding, xLabel, yLabel]
);
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
// Responsive sizing when width/height aren't fixed
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width: w, height: h } = entry.contentRect;
if (w > 0 && h > 0) {
setSize({ width: width ?? w, height: height ?? h });
}
});
ro.observe(el);
return () => ro.disconnect();
}, [width, height]);
const paint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = size.width;
const cssH = size.height;
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right),
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const toPxX = (x: number) => plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
const toPxY = (y: number) =>
plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => [
xMin + ((px - plot.x) / plot.width) * (xMax - xMin),
yMin + (1 - (py - plot.y) / plot.height) * (yMax - yMin),
];
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
}
// clip to plot area so user drawing can't bleed into labels
ctx.save();
ctx.beginPath();
ctx.rect(plot.x, plot.y, plot.width, plot.height);
ctx.clip();
if (showGrid) {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const gx of range(xMin, xMax, effXStep)) {
const px = Math.round(toPxX(gx)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const gy of range(yMin, yMax, effYStep)) {
const py = Math.round(toPxY(gy)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
if (yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
ctx.stroke();
}
// hand off to user drawing code, still clipped to plot area
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore();
// border
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
// ticks + labels (outside clip)
if (showTicks) {
ctx.fillStyle = textColor;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const gx of range(xMin, xMax, effXStep)) {
const px = toPxX(gx);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + 4);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatX(gx), px, plot.y + plot.height + 6);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const gy of range(yMin, yMax, effYStep)) {
const py = toPxY(gy);
ctx.beginPath();
ctx.moveTo(plot.x - 4, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatY(gy), plot.x - 6, py);
}
}
if (xLabel) {
ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
}
if (yLabel) {
ctx.save();
ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(12, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
}, [
size,
pad,
xMin,
xMax,
effXStep,
yMin,
yMax,
effYStep,
showGrid,
showAxes,
showTicks,
xLabel,
yLabel,
formatX,
formatY,
draw,
]);
useEffect(() => {
paint();
}, [paint]);
useImperativeHandle(ref, () => ({
redraw: paint,
getCanvas: () => canvasRef.current,
}));
return (
<div
ref={containerRef}
className={'container'}
style={{
width: width ?? '100%',
height: height ?? '100%',
...style,
}}
>
<canvas
ref={canvasRef}
className={'canvas'}
style={{ width: size.width, height: size.height }}
/>
</div>
);
});
export default LinearGraph;

View File

@ -1,15 +1,29 @@
.gauge-container {
width: 70px;
height: 260px;
.gauge-viewport {
container-type: size;
container-name: gauge;
padding-top: 20px;
padding-bottom: 10px;
position: relative;
flex: 1 1 0;
min-height: 0;
width: auto;
overflow: hidden;
}
.gauge-container {
--s: min(calc(100cqw / 70), calc(100cqh / 240));
position: absolute;
bottom: 0;
left: 50%;
width: calc(70 * var(--s));
height: calc(240 * var(--s));
display: flex;
flex-direction: column;
align-items: center;
transform-origin: top center;
transform: translateX(-50%);
box-sizing: border-box;
}
@ -17,129 +31,100 @@
.gauge-track {
position: relative;
width: 70px;
height: 210px;
width: calc(70 * var(--s));
height: calc(210 * var(--s));
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8px;
margin-bottom: calc(8 * var(--s));
}
.slider {
position: relative;
width: 24px;
width: calc(24 * var(--s));
height: 100%;
cursor: pointer;
touch-action: none;
user-select: none;
}
.track {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
width: 2px;
width: calc(2 * var(--s));
height: 100%;
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.thumb {
position: absolute;
left: 50%;
width: 18px;
width: calc(18 * var(--s));
aspect-ratio: 1;
transform: translate(-50%, 50%);
border-radius: 50%;
background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand);
box-shadow: var(--shadow-sm);
}
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: 10px;
height: 2px;
width: calc(10 * var(--s));
height: calc(2 * var(--s));
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.left {
left: 8px;
}
.right {
right: 8px;
}
.left { left: calc(8 * var(--s)); }
.right { right: calc(8 * var(--s)); }
.value {
width: 100%;
margin-top: 4px;
padding-top: 2px;
padding-bottom: 2px;
padding-right: 4px;
padding-left: 4px;
margin-top: calc(4 * var(--s));
padding: calc(2 * var(--s)) calc(4 * var(--s));
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25em;
white-space: nowrap;
color: var(--accent-brand);
font-size: 16px;
font-family: var(--font-ui);
font-size: var(--font-xxs);
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
overflow: hidden;
@ -148,15 +133,12 @@
.value input {
width: auto;
min-width: 0;
height: 1.4em;
text-align: center;
background: transparent;
border: 1px solid var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
appearance: none;
@ -165,10 +147,7 @@
.value input,
.value-unit {
font-size: inherit;
line-height: 1;
font-family: inherit;
color: inherit;
}
}

View File

@ -0,0 +1,248 @@
import { useMemo, useRef, useState } from 'react';
import './VerticalSlider.css';
type VerticalSliderProps = {
min: number;
max: number;
step: number;
switchStepValue?: number;
switchStep?: number;
value: number;
onChange?: (value: number) => Promise<boolean>;
unit?: string;
className?: string;
style?: React.CSSProperties;
};
function getDecimals(n: number) {
const s = n.toString();
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
}
export default function VerticalSlider({
min,
max,
step,
switchStepValue,
switchStep,
value,
onChange,
unit = '',
className,
style,
}: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value);
const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false);
function getStep(value: number) {
if (switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue) {
return switchStep;
}
return step;
}
const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0),
[step, switchStep]
);
function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input));
const currentStep = getStep(clamped);
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
}
function updateValue(input: number) {
const next = normalizeValue(input);
dragValue.current = next;
setDisplayValue(next);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
return;
}
keyboardEditing.current = true;
e.preventDefault();
const base = keyboardValue.current ?? value;
const currentStep = getStep(base);
let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
next += currentStep;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
next -= currentStep;
}
next = normalizeValue(next);
keyboardValue.current = next;
updateValue(next);
}
async function handleKeyUp() {
if (keyboardValue.current == null) return;
const next = keyboardValue.current;
const success = await onChange?.(next);
keyboardEditing.current = false;
keyboardValue.current = null;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
}
}
function valueFromPointer(clientY: number) {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const percent = 1 - (clientY - rect.top) / rect.height;
const raw = min + percent * (max - min);
updateValue(raw);
}
function startDrag(e: React.PointerEvent<HTMLDivElement>) {
const target = e.currentTarget;
dragging.current = true;
target.setPointerCapture(e.pointerId);
valueFromPointer(e.clientY);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
valueFromPointer(e.clientY);
}
async function endDrag(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging.current) return;
const finalValue = dragValue.current;
const success = await onChange?.(finalValue);
dragging.current = false;
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
}
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
async function commitEditing() {
if (editingText !== null && editingText !== '' && editingText !== '-') {
const parsed = Number(editingText);
if (!Number.isNaN(parsed)) {
const next = normalizeValue(parsed);
setDisplayValue(next);
const success = await onChange?.(next);
if (success === false) {
setDisplayValue(value);
dragValue.current = value;
}
}
}
setEditingText(null);
}
const percent = (displayValue - min) / (max - min);
return (
<div
className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
>
<div
className="gauge-container"
>
<div className="gauge-track">
<div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
<div
className="slider"
ref={sliderRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPointerDown={startDrag}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div>
<div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => (
<span key={i} />
))}
</div>
</div>
<div className="value">
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)}
style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setEditingText(null);
inputRef.current?.blur();
}
}}
/>
<span className="value-unit">{unit}</span>
</div>
</div>
</div>
);
}

View File

@ -69,6 +69,7 @@
cursor: pointer;
padding: 0;
font: inherit;
background: transparent;
border: var(--border-width-active) solid transparent;
@ -133,11 +134,12 @@
gap: var(--space-3);
height: var(--tab-height);
padding: 0 var(--space-3);
border: var(--border-width-active) solid transparent;
border: var(--border-width-active) solid var(--border-hairline);
border-radius: var(--radius-lg);
background: transparent;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--font-md);
cursor: pointer;
box-sizing: border-box;
scroll-snap-align: start;
@ -156,15 +158,19 @@
border-color: var(--accent-brand);
}
.tab span[title] {
.tab-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.tab-led {
height: 40%;
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
@ -173,24 +179,33 @@
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0;
}
.tab-close {
height: 50%;
aspect-ratio: 1 / 1;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border: 0;
border-radius: var(--radius-sm);
margin-left: auto;
padding: 0;
font-size: var(--tab-close-font);
line-height: 1;
color: var(--text-muted);
border-radius: var(--radius-sm);
background: transparent;
}
.tab--active {
@ -246,4 +261,4 @@
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
var(--tab-close-size)
);
}
}

View File

@ -3,7 +3,7 @@ import { DSP, AppPage } from '../../types/types';
import AnimatedLogo from '../../assets/icons/AnimatedLogo';
import { PlusIcon } from '../../assets/icons/PlusIcon';
import { CrownIcon } from '../../assets/icons/CrownIcon';
import { Led } from '../Led/Led';
import Led from '../Led/Led';
import { CloseIcon } from '../../assets/icons/CloseIcon';
import './Topbar.css';
@ -22,6 +22,8 @@ function Tab({
step,
onSelect,
onRemove,
onConnect,
onDisconnect,
}: {
dsp: DSP;
active: boolean;
@ -29,20 +31,62 @@ function Tab({
step: number;
onSelect: () => void;
onRemove: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const isConnected = dsp.status === 'connected';
const handleTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
};
return (
<button
className={'tab' + (active ? ' tab--active' : '') + (dimmed ? ' tab--dimmed' : '')}
<div
className={[
'tab',
active && 'tab--active',
dimmed && 'tab--dimmed',
]
.filter(Boolean)
.join(' ')}
data-step={step}
tabIndex={0}
role="tab"
aria-selected={active}
onClick={onSelect}
onKeyDown={handleTabKeyDown}
>
<span className="tab-led">
<button
type="button"
className="tab-led"
aria-label={
isConnected
? `Disconnect ${dsp.name}`
: `Connect ${dsp.name}`
}
onClick={(e) => {
e.stopPropagation();
if (isConnected) {
onDisconnect(dsp);
} else {
onConnect(dsp);
}
}}
>
<Led status={dsp.status} />
</button>
<span className="tab-name" title={dsp.name}>
{dsp.name}
</span>
<span title={dsp.name}>{dsp.name}</span>
<span
<button
type="button"
className="tab-close"
role="button"
aria-label={`Remove ${dsp.name}`}
onClick={(e) => {
e.stopPropagation();
@ -50,8 +94,8 @@ function Tab({
}}
>
<CloseIcon />
</span>
</button>
</button>
</div>
);
}
@ -64,6 +108,8 @@ function TopBar({
onSelect,
onRemove,
onAddClick,
onConnect,
onDisconnect,
}: {
dsps: DSP[];
selected: number | null;
@ -73,6 +119,8 @@ function TopBar({
onSelect: (id: number) => void;
onRemove: (dsp: DSP) => void;
onAddClick: () => void;
onConnect: (dsp: DSP) => void;
onDisconnect: (dsp: DSP) => void;
}) {
const hasTabs = dsps.length > 0;
const tabstripRef = useRef<HTMLDivElement>(null);
@ -145,6 +193,8 @@ function TopBar({
dimmed={page === 'add-dsp'}
onSelect={() => onSelect(dsp.id)}
onRemove={() => onRemove(dsp)}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
))}
</div>

592
src/hooks/useDSP408.ts Normal file
View File

@ -0,0 +1,592 @@
// hooks/useDSP408.ts
import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { DSP, AppPage } from '../types/types';
import { Channel } from '../types/dsp408State';
import {
createDSP408,
removeDSP408,
connectDSP408,
disconnectDSP408,
getDSP408State,
recallPresetDSP408,
DSP408SetCurrentPresetName,
getDSP408MeterLevels,
} from '../api/dsp408';
type NotifyFunction = (message: string, type: 'success' | 'error', dspName?: string) => void;
type SetDsps = React.Dispatch<React.SetStateAction<DSP[]>>;
// ============================================================
// Helpers
// ============================================================
function updateDSP(setDsps: SetDsps, dspId: number, updater: (dsp: DSP) => DSP) {
setDsps((prev) => prev.map((dsp) => (dsp.id === dspId ? updater(dsp) : dsp)));
}
export type DSP408Device = {
connect: () => Promise<void>;
disconnect: () => Promise<void>;
recallPreset: (index: number) => Promise<boolean>;
setCurrentPresetName: (name: string) => Promise<boolean>;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
};
// ============================================================
// Polling
// ============================================================
function useDSPPolling(
dsps: DSP[],
selected: number | null,
setDsps: React.Dispatch<React.SetStateAction<DSP[]>>
) {
const dspsRef = useRef(dsps);
const selectedRef = useRef(selected);
useEffect(() => {
dspsRef.current = dsps;
}, [dsps]);
useEffect(() => {
selectedRef.current = selected;
}, [selected]);
useEffect(() => {
let stopped = false;
let activeTimeout: ReturnType<typeof setTimeout> | undefined;
let inactiveTimeout: ReturnType<typeof setTimeout> | undefined;
const pollDSP = async (dsp: DSP) => {
// Don't poll paused DSPs
if (dsp.pollingPaused) {
return;
}
try {
const meters = await getDSP408MeterLevels(dsp.id);
if (stopped) return;
setDsps((prev) => prev.map((d) => (d.id === dsp.id ? { ...d, meters } : d)));
} catch (err) {
if (!stopped) {
console.error(`Meter polling failed for ${dsp.name}:`, err);
}
}
};
const activeLoop = async () => {
if (stopped) return;
const activeId = selectedRef.current;
if (activeId !== null) {
const dsp = dspsRef.current.find(
(d) => d.id === activeId && d.status === 'connected' && !d.pollingPaused
);
if (dsp) {
await pollDSP(dsp);
}
}
if (!stopped) {
activeTimeout = setTimeout(activeLoop, 500);
}
};
const inactiveLoop = async () => {
if (stopped) return;
const activeId = selectedRef.current;
const inactiveDsps = dspsRef.current.filter(
(dsp) => dsp.status === 'connected' && dsp.id !== activeId && !dsp.pollingPaused
);
await Promise.all(inactiveDsps.map((dsp) => pollDSP(dsp)));
if (!stopped) {
inactiveTimeout = setTimeout(inactiveLoop, 5000);
}
};
// Start polling
activeLoop();
inactiveLoop();
return () => {
stopped = true;
if (activeTimeout !== undefined) {
clearTimeout(activeTimeout);
}
if (inactiveTimeout !== undefined) {
clearTimeout(inactiveTimeout);
}
};
}, [setDsps]);
}
// ============================================================
// Main hook
// ============================================================
export function useDSP408(
dsps: DSP[],
setDsps: SetDsps,
selected: number | null,
setSelected: React.Dispatch<React.SetStateAction<number | null>>,
setPage: React.Dispatch<React.SetStateAction<AppPage>>,
notify: NotifyFunction
) {
// ----------------------------------------------------------
// Polling control
// ----------------------------------------------------------
const setPollingPaused = (id: number, paused: boolean) => {
updateDSP(setDsps, id, (dsp) => ({
...dsp,
pollingPaused: paused,
}));
};
// Polling is part of the hook.
useDSPPolling(dsps, selected, setDsps);
// ----------------------------------------------------------
// Generic operation wrapper
// ----------------------------------------------------------
async function withPollingPaused<T>(dsp: DSP, operation: () => Promise<T>): Promise<T> {
setPollingPaused(dsp.id, true);
try {
return await operation();
} finally {
setPollingPaused(dsp.id, false);
}
}
// ==========================================================
// Connection
// ==========================================================
async function connectDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'connecting',
}));
try {
await connectDSP408(dsp.id);
const state = await getDSP408State(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'connected',
state,
}));
notify('Connected', 'success', dsp.name);
} catch (err) {
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'disconnected',
}));
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
} finally {
setPollingPaused(dsp.id, false);
}
}
async function disconnectDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
try {
await disconnectDSP408(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'disconnected',
}));
notify('Disconnected', 'success', dsp.name);
} catch (err) {
// If disconnect failed, it is probably still connected.
setPollingPaused(dsp.id, false);
notify(`Disconnect failed: ${String(err)}`, 'error', dsp.name);
}
}
// ==========================================================
// Add / Remove
// ==========================================================
async function addDSP(dsp: Omit<DSP, 'id' | 'status' | 'state' | 'meters' | 'pollingPaused'>) {
try {
const id = await createDSP408(dsp.ip, dsp.port, dsp.deviceId);
const newDsp: DSP = {
id,
...dsp,
status: 'disconnected',
state: null,
meters: null,
pollingPaused: true,
};
setDsps((prev) => [...prev, newDsp]);
setSelected(id);
setPage('view-dsp');
notify('Added successfully', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}
async function removeDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
try {
await removeDSP408(dsp.id);
setDsps((prev) => {
const next = prev.filter((d) => d.id !== dsp.id);
if (selected === dsp.id) {
setSelected(next.length ? next[0].id : null);
}
return next;
});
notify('Removed successfully', 'success', dsp.name);
} catch (err) {
setPollingPaused(dsp.id, false);
notify(`Remove failed: ${String(err)}`, 'error', dsp.name);
}
}
// ==========================================================
// Presets
// ==========================================================
async function recallPreset(dsp: DSP, presetIndex: number) {
return withPollingPaused(dsp, async () => {
try {
const success = await recallPresetDSP408(dsp.id, presetIndex);
if (!success) {
notify('Failed to recall preset', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
return {
...d,
state: {
...d.state,
presets: {
...d.state.presets,
current_index: presetIndex - 1,
},
},
};
});
notify(`Preset ${presetIndex} recalled`, 'success', dsp.name);
return true;
} catch (err) {
notify(`Preset recall failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
async function setCurrentPresetName(dsp: DSP, name: string) {
return withPollingPaused(dsp, async () => {
try {
const paddedName = name.slice(0, 14).padEnd(14, ' ');
const success = await DSP408SetCurrentPresetName(dsp.id, paddedName);
if (!success) {
notify('Failed to rename preset', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const presets = d.state.presets;
const index = presets.current_index;
return {
...d,
state: {
...d.state,
presets: {
...presets,
names: presets.names.map((presetName, i) =>
i === index ? paddedName : presetName
),
modified: presets.modified.map((modified, i) => (i === index ? true : modified)),
},
},
};
});
notify('Preset renamed', 'success', dsp.name);
return true;
} catch (err) {
notify(`Preset rename failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Gain
// ==========================================================
async function setGain(dsp: DSP, channel: Channel, gain: number): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_channel_gain', {
id: dsp.id,
channel,
db: gain,
});
if (!success) {
notify('Failed to set gain', 'error', dsp.name);
return false;
}
const state = await getDSP408State(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
state,
}));
notify('Gain updated', 'success', dsp.name);
return true;
} catch (err) {
notify(`Gain update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Mute
// ==========================================================
async function setMute(dsp: DSP, channel: Channel, muted: boolean): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_mute', {
id: dsp.id,
channel,
muted,
});
if (!success) {
notify('Failed to change mute state', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
if ('Input' in channel) {
const inputChannel = channel.Input;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
input_states: {
...currentConfig.input_states,
[inputChannel]: {
...currentConfig.input_states[inputChannel],
mute: muted,
},
},
},
},
};
}
const outputChannel = channel.Output;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
output_states: {
...currentConfig.output_states,
[outputChannel]: {
...currentConfig.output_states[outputChannel],
mute: muted,
},
},
},
},
};
});
notify(muted ? 'Muted' : 'Unmuted', 'success', dsp.name);
return true;
} catch (err) {
notify(`Mute update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Inverse
// ==========================================================
async function setInverse(dsp: DSP, channel: Channel, inverted: boolean): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_channel_inverse_gain', {
id: dsp.id,
channel,
inverted,
});
if (!success) {
notify('Failed to change phase', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
if ('Input' in channel) {
const inputChannel = channel.Input;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
input_states: {
...currentConfig.input_states,
[inputChannel]: {
...currentConfig.input_states[inputChannel],
phase_inverted: inverted,
},
},
},
},
};
}
const outputChannel = channel.Output;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
output_states: {
...currentConfig.output_states,
[outputChannel]: {
...currentConfig.output_states[outputChannel],
phase_inverted: inverted,
},
},
},
},
};
});
notify(inverted ? 'Phase inverted' : 'Phase normal', 'success', dsp.name);
return true;
} catch (err) {
notify(`Phase update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Return API
// ==========================================================
function forDSP(dsp: DSP): DSP408Device {
return {
connect: () => connectDSP(dsp),
disconnect: () => disconnectDSP(dsp),
recallPreset: (index: number) => recallPreset(dsp, index),
setCurrentPresetName: (name: string) => setCurrentPresetName(dsp, name),
setGain: (channel: Channel, gain: number) => setGain(dsp, channel, gain),
setMute: (channel: Channel, muted: boolean) => setMute(dsp, channel, muted),
setInverse: (channel: Channel, inverted: boolean) => setInverse(dsp, channel, inverted),
};
}
return {
forDSP,
addDSP,
removeDSP,
connectDSP,
disconnectDSP,
recallPreset,
setCurrentPresetName,
setGain,
};
}

View File

@ -10,18 +10,22 @@ function AddDSPForm({
onAdd,
}: {
onCancel: () => void;
onAdd: (d: Omit<DSP, 'status' | 'id'>) => void;
onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters' | 'pollingPaused'>) => void;
}) {
const [form, setForm] = useState({
name: '',
type: DSP_TYPES[0].name,
ip: '192.168.1.100',
port: DSP_TYPES[0].defaultPort,
deviceId: '1',
deviceId: 1,
});
const canSubmit =
form.name.trim().length > 0 && form.ip.trim().length > 0 && form.deviceId.length > 0;
form.name.trim().length > 0 &&
form.ip.trim().length > 0 &&
Number.isInteger(form.deviceId) &&
form.deviceId >= 1 &&
form.deviceId <= 255;
function submit() {
if (!canSubmit) return;
@ -82,7 +86,7 @@ function AddDSPForm({
step={1}
placeholder="1"
value={form.deviceId}
onChange={(e) => setForm({ ...form, deviceId: e.target.value })}
onChange={(e) => setForm({ ...form, deviceId: Number(e.target.value) })}
/>
</div>
</div>

View File

@ -2,12 +2,24 @@
position: fixed;
left: var(--sidebar-width-collapsed);
right: 0;
top: 0;
bottom: 0;
transition: left var(--dur-base) var(--ease-standard);
top: var(--header-height, 0);
top: var(--header-height);
}
.app-root:has(.sidebar-rail--expanded) .dsp-content {
left: var(--sidebar-width-expanded);
}
.dsp-loading-overlay {
position: fixed;
left: var(--sidebar-width-collapsed);
right: 0;
top: var(--header-height, 0);
bottom: 0;
transition: left var(--dur-base) var(--ease-standard);
}
.app-root:has(.sidebar-rail--expanded) .dsp-loading-overlay {
left: var(--sidebar-width-expanded);
}

View File

@ -4,23 +4,19 @@ import Sidebar from '../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon } from '../../assets/icons/AudioIcons';
import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection';
import GainSection from '../../components/dsp408/GainSection/GainSection';
import { NotificationType } from '../../types/types';
import { DSP408Device } from '../../hooks/useDSP408';
import './DspPage.css';
import GateSection from '../../components/dsp408/GateSection/GateSection';
import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay';
import { InputChannel, OutputChannel } from '../../types/dsp408State';
function DSPPage({
dsp,
onConnect,
onDisconnect,
notify,
}: {
dsp: DSP;
onConnect: () => void;
onDisconnect: () => void;
notify: (message: string, type?: NotificationType, dspName?: string) => void;
}) {
function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) {
const [activeSidebar, setActiveSidebar] = useState('overview');
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states;
const sidebarItems = [
{
id: 'overview',
@ -32,15 +28,156 @@ function DSPPage({
label: 'Gain',
icon: <KnobGainIcon />,
},
{
id: 'gate',
label: 'Gate',
icon: <KnobGainIcon />,
},
{
id: 'compressor',
label: 'Compressor',
icon: <KnobGainIcon />,
},
{
id: 'limiter',
label: 'Limiter',
icon: <KnobGainIcon />,
},
{
id: 'delay',
label: 'Delay',
icon: <KnobGainIcon />,
},
{
id: 'matrix',
label: 'Matrix',
icon: <KnobGainIcon />,
},
{
id: 'geq',
label: 'Graphic Equalizer',
icon: <KnobGainIcon />,
},
{
id: 'ina',
label: `In A (${inputStates?.[InputChannel.InA]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'inb',
label: `In B (${inputStates?.[InputChannel.InB]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'inc',
label: `In C (${inputStates?.[InputChannel.InC]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'ind',
label: `In D (${inputStates?.[InputChannel.InD]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out1',
label: `Out 1 (${outputStates?.[OutputChannel.Out1]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out2',
label: `Out 2 (${outputStates?.[OutputChannel.Out2]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out3',
label: `Out 3 (${outputStates?.[OutputChannel.Out3]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out4',
label: `Out 4 (${outputStates?.[OutputChannel.Out4]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out5',
label: `Out 5 (${outputStates?.[OutputChannel.Out5]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out6',
label: `Out 6 (${outputStates?.[OutputChannel.Out6]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out7',
label: `Out 7 (${outputStates?.[OutputChannel.Out7]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out8',
label: `Out 8 (${outputStates?.[OutputChannel.Out8]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'miscellaneous',
label: 'Miscellaneous',
icon: <KnobGainIcon />,
},
];
const presets = dsp.state?.presets.names
? [
{
id: 'f00',
name: 'Factory Preset',
},
...dsp.state.presets.names.map((name, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name,
})),
]
: [
{
id: 'f00',
name: 'Factory Preset',
},
...Array.from({ length: 20 }, (_, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset',
})),
];
const onAppChange = async (id: string) => {
const presetIndex = presets.findIndex((preset) => preset.id === id);
if (presetIndex === -1) return;
setPresetLoading(true);
const success = await dsp408.recallPreset(presetIndex);
if (success) {
setSelectedPreset(presetIndex);
}
setPresetLoading(false);
};
const renderTab = () => {
switch (activeSidebar) {
case 'overview':
return <ConnectionPanel dsp={dsp} onConnect={onConnect} onDisconnect={onDisconnect} />;
return (
<ConnectionPanel dsp={dsp} onConnect={dsp408.connect} onDisconnect={dsp408.disconnect} />
);
case 'gain':
return <GainSection dsp={dsp} notify={notify} />;
return (
<GainSection
dsp={dsp}
setGain={dsp408.setGain}
setMute={dsp408.setMute}
setInverse={dsp408.setInverse}
/>
);
case 'gate':
return <GateSection dsp={dsp} />;
default:
return null;
@ -54,11 +191,21 @@ function DSPPage({
activeId={activeSidebar}
onSelect={setActiveSidebar}
appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange}
onAppRename={dsp408.setCurrentPresetName}
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)}
/>
<div className="dsp-content">{renderTab()}</div>
<LoadingOverlay
active={presetLoading}
label="Recalling preset…"
overlayClassName="dsp-loading-overlay"
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
</div>
);
}

View File

@ -191,3 +191,7 @@ export type DSPState = {
presets: PresetBank;
current_config: DSPConfigState;
};
export interface Meters {
levels: number[];
}

View File

@ -1,3 +1,5 @@
import { DSPState, Meters } from './dsp408State';
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
export type DSP = {
@ -6,8 +8,11 @@ export type DSP = {
type: string;
ip: string;
port: number;
deviceId: string;
deviceId: number;
status: DSPStatus;
state: DSPState | null;
meters: Meters | null;
pollingPaused: boolean;
};
export const DSP_TYPES = [

View File

@ -15,7 +15,12 @@
--accent-down: #ff5c5c;
--accent-connecting: #f5a623;
/* ---------- Type ---------- */
--font-ui: 'Inter', -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
/* ---------- Font ---------- */
--font-xxs: 0.66rem;
--font-xs: 0.75rem;
--font-sm: 0.875rem;
--font-md: 1rem;
@ -50,46 +55,6 @@
--radius-lg: 10px;
--radius-pill: 999px;
/* ---------- Header ---------- */
--header-height: 50px;
--font-size-header: 1rem;
--tab-height: calc(var(--header-height) * 0.75);
/* ---------- Card ---------- */
--card-width: 500px;
--card-control-height: 36px;
/* ---------- Button ---------- */
--disabled-opacity: 0.45;
/* ---------- SideBar ---------- */
--sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3));
--gain-section-height: 40vh;
--sidebar-width-expanded: 22vh;
--sidebar-icon-size: 2vh;
--sidebar-item-size: 4.5vh;
--sidebar-toggle-size: 3vh;
--sidebar-label-size: 1.9vh;
--sidebar-item-gap: 1vh;
--sidebar-item-padding: 1vh;
--font-size-sm: 0.875rem;
/* --tab-add-size: 2.6vh; */
--tab-add-empty-height: 3vh;
--crown-icon-size: 2.2vh;
--tab-add-icon-size: 1.6vh;
--logo-size: 18vh;
/* ---------- Type ---------- */
--font-ui: 'Inter', -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
/* ---------- Motion ---------- */
--dur-fast: 100ms;
--dur-base: 180ms;
@ -106,8 +71,23 @@
--transition-fast: 120ms;
--transition-base: 180ms;
--font-button: 1.5vh;
--font-label: 1.4vh;
--font-title: 1.8vh;
--font-value: 1.6vh;
/* ---------- Header ---------- */
--header-height: 50px;
--font-size-header: 1rem;
--tab-height: calc(var(--header-height) * 0.75);
/* ---------- Card ---------- */
--card-width: 500px;
--card-control-height: 36px;
/* ---------- Button ---------- */
--disabled-opacity: 0.45;
/* ---------- SideBar ---------- */
--sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3));
--sidebar-width-expanded: 200px;
/* ---------- DSP ---------- */
--dsp-control-heigth: 40vh;
}