Compare commits
10 Commits
e79748aa90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06710b68d4 | |||
| 55bffbe350 | |||
| 7ad2db9662 | |||
| 255b1f217e | |||
| eab62e151e | |||
| 54fda168cc | |||
| da322c9df1 | |||
| 31f645ebb2 | |||
| a157b5f198 | |||
| 62d3b22fea |
@ -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())
|
||||
})
|
||||
}
|
||||
@ -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!())
|
||||
|
||||
128
src/App.tsx
128
src/App.tsx
@ -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
31
src/api/dsp408.ts
Normal 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 });
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
54
src/components/LoadingOverlay/LoadingOverlay.css
Normal file
54
src/components/LoadingOverlay/LoadingOverlay.css
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/components/LoadingOverlay/LoadingOverlay.tsx
Normal file
32
src/components/LoadingOverlay/LoadingOverlay.tsx
Normal 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;
|
||||
@ -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 ---------- */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
31
src/components/dsp408/GateSection/GateSection.tsx
Normal file
31
src/components/dsp408/GateSection/GateSection.tsx
Normal 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;
|
||||
89
src/components/dsp408/LinearGraph/LinearGraph.css
Normal file
89
src/components/dsp408/LinearGraph/LinearGraph.css
Normal 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 */
|
||||
}
|
||||
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,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;
|
||||
}
|
||||
}
|
||||
248
src/components/dsp408/Slider/VerticalSlider.tsx
Normal file
248
src/components/dsp408/Slider/VerticalSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
592
src/hooks/useDSP408.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -191,3 +191,7 @@ export type DSPState = {
|
||||
presets: PresetBank;
|
||||
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 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 = [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user