feat: add keep alive
This commit is contained in:
@ -203,4 +203,33 @@ pub async fn set_channel_inverse_gain(
|
|||||||
|
|
||||||
Ok(r)
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -16,7 +16,9 @@ pub fn run() {
|
|||||||
commands::get_dsp_state,
|
commands::get_dsp_state,
|
||||||
commands::set_channel_gain,
|
commands::set_channel_gain,
|
||||||
commands::set_mute,
|
commands::set_mute,
|
||||||
commands::set_channel_inverse_gain
|
commands::set_channel_inverse_gain,
|
||||||
|
commands::recall_preset,
|
||||||
|
commands::get_meter_levels,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
36
src/App.tsx
36
src/App.tsx
@ -8,9 +8,10 @@ import DSPPage from './pages/DspPage/DspPage';
|
|||||||
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
import AddDSPForm from './pages/AddDspPage/AddDspPage';
|
||||||
import { useNotifications } from './hooks/useNotifications';
|
import { useNotifications } from './hooks/useNotifications';
|
||||||
import Notifications from './components/Notifications/Notifications';
|
import Notifications from './components/Notifications/Notifications';
|
||||||
import { DSPState } from './types/dsp408State';
|
|
||||||
import CreditsPage from './pages/CreditsPage/CreditsPage';
|
import CreditsPage from './pages/CreditsPage/CreditsPage';
|
||||||
import HomePage from './pages/HomePage/HomePage';
|
import HomePage from './pages/HomePage/HomePage';
|
||||||
|
import { getDSP408State, connectDSP408, disconnectDSP408 } from './api/dsp408';
|
||||||
|
import { useDSPPolling } from './hooks/useDSPPolling';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [dsps, setDsps] = useState<DSP[]>([]);
|
const [dsps, setDsps] = useState<DSP[]>([]);
|
||||||
@ -18,7 +19,7 @@ function App() {
|
|||||||
const [page, setPage] = useState<AppPage>('home');
|
const [page, setPage] = useState<AppPage>('home');
|
||||||
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
|
||||||
|
|
||||||
async function addDSP(dsp: Omit<DSP, 'id' | 'status'>) {
|
async function addDSP(dsp: Omit<DSP, 'id' | 'status' | 'state' | 'meters'>) {
|
||||||
try {
|
try {
|
||||||
const id = await invoke<number>('create_dsp408', {
|
const id = await invoke<number>('create_dsp408', {
|
||||||
ip: dsp.ip,
|
ip: dsp.ip,
|
||||||
@ -30,6 +31,8 @@ function App() {
|
|||||||
id,
|
id,
|
||||||
...dsp,
|
...dsp,
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
|
state: null,
|
||||||
|
meters: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDsps((prev) => [...prev, newDsp]);
|
setDsps((prev) => [...prev, newDsp]);
|
||||||
@ -69,17 +72,13 @@ function App() {
|
|||||||
setStatus(dsp.id, 'connecting');
|
setStatus(dsp.id, 'connecting');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('connect_dsp408', {
|
await connectDSP408(dsp.id);
|
||||||
id: dsp.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Connected');
|
|
||||||
|
|
||||||
setStatus(dsp.id, 'connected');
|
setStatus(dsp.id, 'connected');
|
||||||
|
|
||||||
const state = await getDSPState(dsp);
|
const state = await getDSP408State(dsp.id);
|
||||||
|
|
||||||
console.log(state);
|
setDsps((prev) => prev.map((d) => (d.id === dsp.id ? { ...d, state } : d)));
|
||||||
|
|
||||||
notify('Connected', 'success', dsp.name);
|
notify('Connected', 'success', dsp.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -91,9 +90,7 @@ function App() {
|
|||||||
|
|
||||||
async function disconnectDSP(dsp: DSP) {
|
async function disconnectDSP(dsp: DSP) {
|
||||||
try {
|
try {
|
||||||
await invoke('disconnect_dsp408', {
|
await disconnectDSP408(dsp.id);
|
||||||
id: dsp.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setStatus(dsp.id, 'disconnected');
|
setStatus(dsp.id, 'disconnected');
|
||||||
|
|
||||||
@ -103,26 +100,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function setStatus(id: number, status: DSPStatus) {
|
||||||
setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d)));
|
setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
|
||||||
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
|
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
|
||||||
|
useDSPPolling(dsps, selected, setDsps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
|||||||
25
src/api/dsp408.ts
Normal file
25
src/api/dsp408.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { DSPState, Meters } from '../types/dsp408State';
|
||||||
|
|
||||||
|
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
|
||||||
|
invoke<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 recallPreset = (id: number, presetIndex: number) =>
|
||||||
|
invoke<boolean>('recall_preset', {
|
||||||
|
id,
|
||||||
|
presetIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });
|
||||||
@ -264,3 +264,132 @@
|
|||||||
.app-root--sidebar-expanded .stage--with-sidebar {
|
.app-root--sidebar-expanded .stage--with-sidebar {
|
||||||
margin-left: var(--sidebar-width-expanded);
|
margin-left: var(--sidebar-width-expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-app-combobox {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease-standard),
|
||||||
|
max-width var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-rail--expanded .sidebar-app-combobox {
|
||||||
|
opacity: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: var(--dur-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-trigger {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
height: calc(var(--header-height) * 0.6);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-trigger:hover,
|
||||||
|
.sidebar-app-trigger--open {
|
||||||
|
border-color: var(--accent-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-trigger-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-trigger-caret {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-trigger--open .sidebar-app-trigger-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-input {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(var(--header-height) * 0.6);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--accent-brand);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-listbox {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--space-1));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-1);
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-option {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-option--highlight {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-app-option--selected {
|
||||||
|
color: var(--accent-brand);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.sidebar-rail--expanded .sidebar-app-select {
|
||||||
|
opacity: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
padding-inline: var(--space-2);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: var(--dur-fast);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
|
||||||
|
|
||||||
@ -8,6 +8,11 @@ export type SidebarItem = {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SidebarAppOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
function SidebarButton({
|
function SidebarButton({
|
||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
@ -31,11 +36,178 @@ function SidebarButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppCombobox({
|
||||||
|
appOptions,
|
||||||
|
selectedAppId,
|
||||||
|
appName,
|
||||||
|
onAppChange,
|
||||||
|
onAppRename,
|
||||||
|
}: {
|
||||||
|
appOptions: SidebarAppOption[];
|
||||||
|
selectedAppId?: string;
|
||||||
|
appName: string;
|
||||||
|
onAppChange?: (id: string) => void;
|
||||||
|
onAppRename?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(currentOption?.name ?? appName);
|
||||||
|
const [highlight, setHighlight] = useState(0);
|
||||||
|
|
||||||
|
const rootRef = useRef<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();
|
||||||
|
setEditing(true);
|
||||||
|
setOpen(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename() {
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
setEditing(false);
|
||||||
|
if (!trimmed) {
|
||||||
|
setInputValue(currentOption?.name ?? appName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matched = appOptions.find((o) => o.name === trimmed);
|
||||||
|
if (matched) {
|
||||||
|
if (matched.id !== selectedAppId) onAppChange?.(matched.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed !== currentOption?.name) {
|
||||||
|
onAppRename?.(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (editing) {
|
||||||
|
if (e.key === 'Enter') inputRef.current?.blur();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setInputValue(currentOption?.name ?? appName);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
openList();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlight((h) => Math.min(h + 1, appOptions.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlight((h) => Math.max(h - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(appOptions[highlight]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-app-combobox" ref={rootRef}>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="sidebar-app-input"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
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({
|
export default function Sidebar({
|
||||||
items,
|
items,
|
||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
appName,
|
appName,
|
||||||
|
appOptions,
|
||||||
|
selectedAppId,
|
||||||
|
onAppChange,
|
||||||
|
onAppRename,
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
@ -43,10 +215,15 @@ export default function Sidebar({
|
|||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
appName: string;
|
appName: string;
|
||||||
|
appOptions?: SidebarAppOption[];
|
||||||
|
selectedAppId?: string;
|
||||||
|
onAppChange?: (id: string) => void;
|
||||||
|
onAppRename?: (name: string) => void;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasOptions = appOptions && appOptions.length > 0;
|
||||||
|
|
||||||
const updateFade = () => {
|
const updateFade = () => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@ -71,7 +248,17 @@ export default function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}>
|
<nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<span className="sidebar-app-name">{appName}</span>
|
{hasOptions ? (
|
||||||
|
<AppCombobox
|
||||||
|
appOptions={appOptions!}
|
||||||
|
selectedAppId={selectedAppId}
|
||||||
|
appName={appName}
|
||||||
|
onAppChange={onAppChange}
|
||||||
|
onAppRename={onAppRename}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="sidebar-app-name">{appName}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="sidebar-toggle"
|
className="sidebar-toggle"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
|||||||
@ -39,5 +39,5 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: var(--space-4);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
.gain-section {
|
.gain-section {
|
||||||
position: absolute; /* was: fixed — now relative to .dsp-content, which is the fixed+inset parent */
|
position: absolute;
|
||||||
left: 0; /* was: var(--sidebar-width-collapsed) — .dsp-content already excludes the sidebar */
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
||||||
height: var(--gain-section-height);
|
height: var(--dsp-control-heigth);
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@ -18,65 +17,77 @@
|
|||||||
|
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: 1px solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-panels {
|
.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%;
|
height: 100%;
|
||||||
min-width: 0;
|
display: flex;
|
||||||
min-height: 0;
|
gap: var(--space-4);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-panel-frame {
|
.gain-panel-frame {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: calc(100% - 8px);
|
height: calc(100% - 2 * var(--space-1));
|
||||||
aspect-ratio: var(--panel-w) / var(--panel-h); /* now driven by JS, not duplicated */
|
margin: var(--space-1) 0;
|
||||||
margin: 4px 0;
|
}
|
||||||
|
|
||||||
|
.gain-panel-viewport {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 90 / 400;
|
||||||
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
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 {
|
.gain-panel {
|
||||||
/* width/height now set inline from DESIGN_WIDTH/HEIGHT, drop the fixed px here */
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: center center;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
border: 1px solid var(--border-hairline);
|
|
||||||
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 8px;
|
gap: var(--space-2);
|
||||||
background: var(--bg-void);
|
background: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-panel__title {
|
.gain-panel__title {
|
||||||
|
flex: 0 0 auto; /* NEW: never let this shrink */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 12px;
|
font-size: var(--font-sm);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
padding-top: var(--space-2);
|
||||||
padding-bottom: 6px;
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||||
border-bottom: 1px solid var(--border-hairline);
|
}
|
||||||
|
|
||||||
|
.channel-group__label {
|
||||||
|
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,37 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
|
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
|
||||||
import { DSP } from '../../../types/types';
|
import { DSP } from '../../../types/types';
|
||||||
import { NotificationType } from '../../../types/types';
|
import { NotificationType } from '../../../types/types';
|
||||||
import { useState } from 'react';
|
|
||||||
import Button from '../../Button/Button';
|
import Button from '../../Button/Button';
|
||||||
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../ChannelGroup/ChannelGroup';
|
||||||
|
import { LinearGraph } from '../LinearGraph/LinearGraph';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import './GainSection.css';
|
import './GainSection.css';
|
||||||
|
|
||||||
const DESIGN_WIDTH = 90;
|
const PANEL_DESIGN_WIDTH = 90;
|
||||||
const DESIGN_HEIGHT = 380;
|
const PANEL_DESIGN_HEIGHT = 400;
|
||||||
|
|
||||||
|
function ChannelGroupScaled({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const { height } = entries[0].contentRect;
|
||||||
|
if (height === 0) return;
|
||||||
|
setScale(height / PANEL_DESIGN_HEIGHT); // height is the only free axis here
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} style={{ height: '100%', '--panel-scale': scale } as React.CSSProperties}>
|
||||||
|
<ChannelGroup title={title}>{children}</ChannelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GainPanel({
|
function GainPanel({
|
||||||
dsp,
|
dsp,
|
||||||
@ -78,6 +102,8 @@ function GainPanel({
|
|||||||
|
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [inverted, setInverted] = useState(false);
|
const [inverted, setInverted] = useState(false);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
|
||||||
const toggleMute = async () => {
|
const toggleMute = async () => {
|
||||||
const next = !muted;
|
const next = !muted;
|
||||||
@ -91,9 +117,28 @@ function GainPanel({
|
|||||||
await setInverse(next);
|
await setInverse(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="gain-panel-frame">
|
<div className="gain-panel-viewport" ref={viewportRef}>
|
||||||
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}>
|
<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>
|
<div className="gain-panel__title">{title}</div>
|
||||||
|
|
||||||
<VerticalSlider
|
<VerticalSlider
|
||||||
@ -104,12 +149,12 @@ function GainPanel({
|
|||||||
switchStep={0.1}
|
switchStep={0.1}
|
||||||
unit=" dB"
|
unit=" dB"
|
||||||
onChange={setGain}
|
onChange={setGain}
|
||||||
|
style={{ width: 70, height: 260, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
|
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
|
||||||
{muted ? 'Muted' : 'Mute'}
|
{muted ? 'Muted' : 'Mute'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
className={inverted ? 'active-warning' : ''}
|
className={inverted ? 'active-warning' : ''}
|
||||||
@ -148,21 +193,48 @@ function GainSection({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gain-section">
|
<>
|
||||||
<div className="gain-panels">
|
<div className="linear-graph-section">
|
||||||
<ChannelGroup title="Input">
|
<LinearGraph
|
||||||
{inputs.map(({ channel, title }) => (
|
xMin={20}
|
||||||
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
|
xMax={20000}
|
||||||
))}
|
yMin={-24}
|
||||||
</ChannelGroup>
|
yMax={24}
|
||||||
|
yStep={6}
|
||||||
<ChannelGroup title="Output">
|
xLabel="Hz"
|
||||||
{outputs.map(({ channel, title }) => (
|
yLabel="dB"
|
||||||
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
|
draw={({ ctx, toPx }) => {
|
||||||
))}
|
// placeholder flat 0 dB response — swap for real EQ/filter data.
|
||||||
</ChannelGroup>
|
// 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>
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,118 +1,4 @@
|
|||||||
.gate-section {
|
.rerere {
|
||||||
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%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
width: 10%;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/components/dsp408/GateSection/GateSection.tsx
Normal file
35
src/components/dsp408/GateSection/GateSection.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
notify,
|
||||||
|
}: {
|
||||||
|
dsp: DSP;
|
||||||
|
notify: (message: string, type?: NotificationType, dspName?: string) => void;
|
||||||
|
}) {
|
||||||
|
console.log(dsp, notify);
|
||||||
|
return (
|
||||||
|
<div className="rerere">
|
||||||
|
<VerticalSlider
|
||||||
|
min={-60}
|
||||||
|
max={12}
|
||||||
|
step={0.5}
|
||||||
|
switchStepValue={-10}
|
||||||
|
switchStep={0.1}
|
||||||
|
unit=" dB"
|
||||||
|
onChange={(_f: number) => {
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GateSection;
|
||||||
93
src/components/dsp408/LinearGraph/LinearGraph.css
Normal file
93
src/components/dsp408/LinearGraph/LinearGraph.css
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-group__label {
|
||||||
|
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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 */
|
||||||
|
}
|
||||||
346
src/components/dsp408/LinearGraph/LinearGraph.tsx
Normal file
346
src/components/dsp408/LinearGraph/LinearGraph.tsx
Normal 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;
|
||||||
@ -1,25 +1,27 @@
|
|||||||
.gauge-viewport {
|
.gauge-viewport {
|
||||||
container-type: inline-size;
|
position: relative;
|
||||||
container-name: gauge;
|
|
||||||
|
|
||||||
width: 100%;
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: auto;
|
||||||
aspect-ratio: 70 / 260;
|
aspect-ratio: 70 / 260;
|
||||||
|
|
||||||
box-sizing: border-box;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge-container {
|
.gauge-container {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
padding-top: 28.571cqw; /* 20px */
|
width: 70px;
|
||||||
padding-bottom: 14.286cqw; /* 10px */
|
height: 240px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
transform-origin: top center;
|
transform-origin: center center;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@ -27,23 +29,21 @@
|
|||||||
.gauge-track {
|
.gauge-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: 100%;
|
width: 70px;
|
||||||
height: 300cqw; /* 210px */
|
height: 210px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin-bottom: 11.429cqw; /* 8px */
|
margin-bottom: 8px;
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: 34.286cqw; /* 24px */
|
width: 24px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
||||||
width: 2.857cqw; /* 2px */
|
width: 2px;
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
||||||
width: 25.714cqw; /* 18px */
|
width: 18px;
|
||||||
|
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|
||||||
@ -106,9 +106,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ticks span {
|
.ticks span {
|
||||||
width: 14.286cqw; /* 10px */
|
width: 10px;
|
||||||
|
|
||||||
height: 2.857cqw; /* 2px */
|
height: 2px;
|
||||||
|
|
||||||
background: var(--border-hairline);
|
background: var(--border-hairline);
|
||||||
|
|
||||||
@ -116,22 +116,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
left: 11.429cqw; /* 8px */
|
left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
right: 11.429cqw; /* 8px */
|
right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
margin-top: 5.714cqw; /* 4px */
|
margin-top: 4px;
|
||||||
|
|
||||||
padding-top: 2.857cqw; /* 2px */
|
padding-top: 2px;
|
||||||
padding-bottom: 2.857cqw; /* 2px */
|
padding-bottom: 2px;
|
||||||
padding-right: 5.714cqw; /* 4px */
|
padding-right: 4px;
|
||||||
padding-left: 5.714cqw; /* 4px */
|
padding-left: 4px;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
@ -146,9 +146,10 @@
|
|||||||
|
|
||||||
color: var(--accent-brand);
|
color: var(--accent-brand);
|
||||||
|
|
||||||
font-size: 22.857cqw;
|
font-size: var(--font-xxs);
|
||||||
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
@ -167,7 +168,7 @@
|
|||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
border: 1px solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,8 @@ import { useMemo, useRef, useState, useEffect } from 'react';
|
|||||||
|
|
||||||
import './VerticalSlider.css';
|
import './VerticalSlider.css';
|
||||||
|
|
||||||
// Design size 260*70 (height*width) — kept as the reference ratio.
|
|
||||||
// All CSS dimensions are expressed in container-query width units (cqw)
|
|
||||||
// relative to this DESIGN_WIDTH, so the whole component scales together
|
|
||||||
// and preserves this aspect ratio at any rendered size.
|
|
||||||
|
|
||||||
const DESIGN_WIDTH = 70;
|
const DESIGN_WIDTH = 70;
|
||||||
|
const DESIGN_HEIGHT = 240;
|
||||||
|
|
||||||
type VerticalSliderProps = {
|
type VerticalSliderProps = {
|
||||||
min: number;
|
min: number;
|
||||||
@ -59,6 +55,24 @@ export default function VerticalSlider({
|
|||||||
const thumbRef = useRef<HTMLDivElement>(null);
|
const thumbRef = useRef<HTMLDivElement>(null);
|
||||||
const keyboardEditing = useRef(false);
|
const keyboardEditing = useRef(false);
|
||||||
|
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
if (width === 0 || height === 0) return;
|
||||||
|
setScale(Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT));
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyboardEditing.current) return;
|
if (keyboardEditing.current) return;
|
||||||
|
|
||||||
@ -76,26 +90,6 @@ export default function VerticalSlider({
|
|||||||
[step, switchStep]
|
[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]);
|
|
||||||
|
|
||||||
// Computed in "design pixels" (i.e. assuming the component is rendered at
|
|
||||||
// DESIGN_WIDTH px wide), then converted below to cqw so it scales with
|
|
||||||
// the actual rendered size instead of staying a fixed pixel value.
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Convert the "design px" font size into cqw units relative to the
|
|
||||||
// component's own width, so it scales proportionally with everything else.
|
|
||||||
const valueFontSizeCqw = useMemo(() => (valueFontSize / DESIGN_WIDTH) * 100, [valueFontSize]);
|
|
||||||
|
|
||||||
function normalizeValue(input: number) {
|
function normalizeValue(input: number) {
|
||||||
const clamped = Math.min(max, Math.max(min, input));
|
const clamped = Math.min(max, Math.max(min, input));
|
||||||
const snapped = Math.round(clamped / currentStep) * currentStep;
|
const snapped = Math.round(clamped / currentStep) * currentStep;
|
||||||
@ -202,8 +196,15 @@ export default function VerticalSlider({
|
|||||||
const percent = (value - min) / (max - min);
|
const percent = (value - min) / (max - min);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
|
<div
|
||||||
<div className="gauge-container">
|
className={['gauge-viewport', className].filter(Boolean).join(' ')}
|
||||||
|
style={style}
|
||||||
|
ref={viewportRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="gauge-container"
|
||||||
|
style={{ transform: `translate(-50%, -50%) scale(${scale})` }}
|
||||||
|
>
|
||||||
<div className="gauge-track">
|
<div className="gauge-track">
|
||||||
<div className="ticks left">
|
<div className="ticks left">
|
||||||
{Array.from({ length: 15 }).map((_, i) => (
|
{Array.from({ length: 15 }).map((_, i) => (
|
||||||
@ -233,7 +234,7 @@ export default function VerticalSlider({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="value" style={{ fontSize: `${valueFontSizeCqw}cqw` }}>
|
<div className="value">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
82
src/hooks/useDSPPolling.ts
Normal file
82
src/hooks/useDSPPolling.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { DSP } from '../types/types';
|
||||||
|
import { getDSP408MeterLevels } from '../api/dsp408';
|
||||||
|
|
||||||
|
export function useDSPPolling(
|
||||||
|
dsps: DSP[],
|
||||||
|
selected: number | null,
|
||||||
|
setDsps: React.Dispatch<React.SetStateAction<DSP[]>>
|
||||||
|
) {
|
||||||
|
const dspsRef = useRef(dsps);
|
||||||
|
const selectedRef = useRef(selected);
|
||||||
|
|
||||||
|
// Keep refs up to date without restarting the polling loops
|
||||||
|
useEffect(() => {
|
||||||
|
dspsRef.current = dsps;
|
||||||
|
}, [dsps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedRef.current = selected;
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const pollDSP = async (dsp: DSP) => {
|
||||||
|
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');
|
||||||
|
|
||||||
|
if (dsp) {
|
||||||
|
await pollDSP(dsp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll inactive connected DSPs
|
||||||
|
await Promise.all(inactiveDsps.map((dsp) => pollDSP(dsp)));
|
||||||
|
|
||||||
|
if (!stopped) {
|
||||||
|
setTimeout(inactiveLoop, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start both loops
|
||||||
|
activeLoop();
|
||||||
|
inactiveLoop();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
}, [setDsps]);
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ function AddDSPForm({
|
|||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onAdd: (d: Omit<DSP, 'status' | 'id'>) => void;
|
onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters'>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import ConnectionPanel from '../../components/dsp408/ConnectionSection/Connectio
|
|||||||
import GainSection from '../../components/dsp408/GainSection/GainSection';
|
import GainSection from '../../components/dsp408/GainSection/GainSection';
|
||||||
import { NotificationType } from '../../types/types';
|
import { NotificationType } from '../../types/types';
|
||||||
import './DspPage.css';
|
import './DspPage.css';
|
||||||
|
import GateSection from '../../components/dsp408/GateSection/GateSection';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
function DSPPage({
|
function DSPPage({
|
||||||
dsp,
|
dsp,
|
||||||
@ -129,6 +131,41 @@ function DSPPage({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const presets = dsp.state?.presets.names?.map((name, index) => ({
|
||||||
|
id: index === 0 ? 'f00' : `u${String(index).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 selectedPreset = dsp.state?.presets.current_index ?? 0;
|
||||||
|
|
||||||
|
const recallPreset = async (presetIndex: number) => {
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('recall_preset', {
|
||||||
|
id: dsp.id,
|
||||||
|
presetIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
notify(`Preset ${presetIndex} recalled`, 'success', dsp.name);
|
||||||
|
} else {
|
||||||
|
notify('Failed to recall preset', 'error', dsp.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Preset recall failed: ${String(err)}`, 'error', dsp.name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderTab = () => {
|
const renderTab = () => {
|
||||||
switch (activeSidebar) {
|
switch (activeSidebar) {
|
||||||
case 'overview':
|
case 'overview':
|
||||||
@ -137,6 +174,9 @@ function DSPPage({
|
|||||||
case 'gain':
|
case 'gain':
|
||||||
return <GainSection dsp={dsp} notify={notify} />;
|
return <GainSection dsp={dsp} notify={notify} />;
|
||||||
|
|
||||||
|
case 'gate':
|
||||||
|
return <GateSection dsp={dsp} notify={notify} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -149,6 +189,15 @@ function DSPPage({
|
|||||||
activeId={activeSidebar}
|
activeId={activeSidebar}
|
||||||
onSelect={setActiveSidebar}
|
onSelect={setActiveSidebar}
|
||||||
appName={dsp.name}
|
appName={dsp.name}
|
||||||
|
appOptions={presets}
|
||||||
|
selectedAppId={presets[selectedPreset]?.id}
|
||||||
|
onAppChange={(id) => {
|
||||||
|
const presetIndex = presets.findIndex((preset) => preset.id === id);
|
||||||
|
|
||||||
|
if (presetIndex !== -1) {
|
||||||
|
recallPreset(presetIndex);
|
||||||
|
}
|
||||||
|
}}
|
||||||
expanded={sidebarExpanded}
|
expanded={sidebarExpanded}
|
||||||
onToggle={() => setSidebarExpanded((v) => !v)}
|
onToggle={() => setSidebarExpanded((v) => !v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -191,3 +191,7 @@ export type DSPState = {
|
|||||||
presets: PresetBank;
|
presets: PresetBank;
|
||||||
current_config: DSPConfigState;
|
current_config: DSPConfigState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Meters {
|
||||||
|
levels: number[];
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { DSPState, Meters } from './dsp408State';
|
||||||
|
|
||||||
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
|
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
|
||||||
|
|
||||||
export type DSP = {
|
export type DSP = {
|
||||||
@ -8,6 +10,8 @@ export type DSP = {
|
|||||||
port: number;
|
port: number;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
status: DSPStatus;
|
status: DSPStatus;
|
||||||
|
state: DSPState | null;
|
||||||
|
meters: Meters | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DSP_TYPES = [
|
export const DSP_TYPES = [
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||||
|
|
||||||
/* ---------- Font ---------- */
|
/* ---------- Font ---------- */
|
||||||
|
--font-xxs: 0.66rem;
|
||||||
--font-xs: 0.75rem;
|
--font-xs: 0.75rem;
|
||||||
--font-sm: 0.875rem;
|
--font-sm: 0.875rem;
|
||||||
--font-md: 1rem;
|
--font-md: 1rem;
|
||||||
@ -86,4 +87,7 @@
|
|||||||
/* ---------- SideBar ---------- */
|
/* ---------- SideBar ---------- */
|
||||||
--sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3));
|
--sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3));
|
||||||
--sidebar-width-expanded: 200px;
|
--sidebar-width-expanded: 200px;
|
||||||
|
|
||||||
|
/* ---------- DSP ---------- */
|
||||||
|
--dsp-control-heigth: 40vh;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user