feat: implement copy and link
This commit is contained in:
@@ -8,4 +8,9 @@ This template should help get you started developing with Tauri, React and Types
|
||||
|
||||
# TODO
|
||||
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
|
||||
- Check render to improve perfoormances
|
||||
- Check render to improve perfoormances
|
||||
- Dont draw geq/peq on gain if bypass
|
||||
- Diplay windows on reset geq, peq
|
||||
- Preset change dont reset
|
||||
|
||||
- Check end to end behavioour
|
||||
@@ -180,7 +180,7 @@ pub async fn store_current_configuration(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x23` -- TODO
|
||||
// Command `0x23`
|
||||
#[tauri::command]
|
||||
pub async fn factory_reset(
|
||||
state: State<'_, AppState>,
|
||||
@@ -207,7 +207,7 @@ pub async fn set_current_preset_name(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x2A` -- TODO
|
||||
// Command `0x2A`
|
||||
#[tauri::command]
|
||||
pub async fn copy_channel(
|
||||
state: State<'_, AppState>,
|
||||
@@ -238,7 +238,7 @@ pub async fn unlock_device(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x2F` -- TODO
|
||||
// Command `0x2F`
|
||||
#[tauri::command]
|
||||
pub async fn lock_device(
|
||||
state: State<'_, AppState>,
|
||||
@@ -399,7 +399,7 @@ pub async fn set_delay(
|
||||
})
|
||||
}
|
||||
|
||||
// Command `0x39` -- TODO
|
||||
// Command `0x39`
|
||||
#[tauri::command]
|
||||
pub async fn set_input_source(
|
||||
state: State<'_, AppState>,
|
||||
|
||||
@@ -84,3 +84,188 @@ export function TextIcon({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlidersVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M10 8H14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 21V12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 8V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
|
||||
<path d="M17 16H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M19 21V16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
|
||||
<path d="M3 14H7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M5 10V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M5 21V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GainIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 12H7M17 12H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 4.5L17 12L7 19.5V4.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GateIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21H12L20 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompressorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L11 13L21 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LimiterIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 3V21H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L11 8H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DelayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4 5V19M12 8.5V15.5M20 11V13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatrixIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M3 7C11 7 11 4 21 4" />
|
||||
<path d="M3 7C11 7 11 20 21 20" />
|
||||
<path d="M3 17C11 17 11 12 21 12" />
|
||||
<circle cx="3" cy="7" r="1.6" fill="none" />
|
||||
<circle cx="3" cy="17" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="4" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="12" r="1.6" fill="none" />
|
||||
<circle cx="21" cy="20" r="1.6" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M2 12C2 12 5.5 5 12 5C18.5 5 22 12 22 12C22 12 18.5 19 12 19C5.5 19 2 12 2 12Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3L21 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.6 5.2C11.05 5.07 11.52 5 12 5C18.5 5 22 12 22 12C22 12 20.7 14.6 18.3 16.5M6.2 6.2C3.5 8.1 2 12 2 12C2 12 5.5 19 12 19C13.2 19 14.3 18.8 15.3 18.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.9 9.9C9.35 10.45 9 11.2 9 12C9 13.66 10.34 15 12 15C12.8 15 13.55 14.65 14.1 14.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
.choice-select__chevron {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 5%;
|
||||
right: 2%;
|
||||
bottom: 0;
|
||||
|
||||
width: 4cqh;
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
.choice-select__chevron svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ function Tab({
|
||||
onRemove,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
actions,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
active: boolean;
|
||||
@@ -33,6 +34,7 @@ function Tab({
|
||||
onRemove: () => void;
|
||||
onConnect: (dsp: DSP) => void;
|
||||
onDisconnect: (dsp: DSP) => void;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const isConnected = dsp.status === 'connected';
|
||||
|
||||
@@ -76,6 +78,8 @@ function Tab({
|
||||
{dsp.name}
|
||||
</span>
|
||||
|
||||
{actions}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
|
||||
import { InfoIcon, KnobGainIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
||||
import { BoltIcon, CompressorIcon, DelayIcon, GainIcon, GateIcon, InfoIcon, KnobGainIcon, LimiterIcon, MatrixIcon, SlidersVerticalIcon, TextIcon } from '../../../../assets/icons/AudioIcons';
|
||||
import ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||
import GainSection from '../GainSection/GainSection';
|
||||
import CompressorSection from '../CompressorSection/CompressorSection';
|
||||
@@ -23,63 +23,80 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
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 inputStates = dsp.state.current_config.input_states;
|
||||
const outputStates = dsp.state.current_config.output_states;
|
||||
|
||||
const sidebarItems: SidebarItem<SidebarId>[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||
{ id: 'gain', 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: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
|
||||
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> },
|
||||
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
|
||||
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
|
||||
{ id: 'gain', label: 'Gain', icon: <GainIcon /> },
|
||||
{ id: 'gate', label: 'Gate', icon: <GateIcon /> },
|
||||
{ id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
|
||||
{ id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
|
||||
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
|
||||
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
|
||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <SlidersVerticalIcon /> },
|
||||
{
|
||||
id: 'ina',
|
||||
label: inputStates[InputChannel.InA].name,
|
||||
icon: <TextIcon>In A</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'inb',
|
||||
label: inputStates[InputChannel.InB].name,
|
||||
icon: <TextIcon>In B</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'inc',
|
||||
label: inputStates[InputChannel.InC].name,
|
||||
icon: <TextIcon>In C</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'ind',
|
||||
label: inputStates[InputChannel.InD].name,
|
||||
icon: <TextIcon>In D</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out1',
|
||||
label: `${outputStates[OutputChannel.Out1].name}`,
|
||||
label: outputStates[OutputChannel.Out1].name,
|
||||
icon: <TextIcon>Out 1</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out2',
|
||||
label: `${outputStates[OutputChannel.Out2].name}`,
|
||||
label: outputStates[OutputChannel.Out2].name,
|
||||
icon: <TextIcon>Out 2</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out3',
|
||||
label: `${outputStates[OutputChannel.Out3].name}`,
|
||||
label: outputStates[OutputChannel.Out3].name,
|
||||
icon: <TextIcon>Out 3</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out4',
|
||||
label: `${outputStates[OutputChannel.Out4].name}`,
|
||||
label: outputStates[OutputChannel.Out4].name,
|
||||
icon: <TextIcon>Out 4</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out5',
|
||||
label: `${outputStates[OutputChannel.Out5].name}`,
|
||||
label: outputStates[OutputChannel.Out5].name,
|
||||
icon: <TextIcon>Out 5</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out6',
|
||||
label: `${outputStates[OutputChannel.Out6].name}`,
|
||||
label: outputStates[OutputChannel.Out6].name,
|
||||
icon: <TextIcon>Out 6</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out7',
|
||||
label: `${outputStates[OutputChannel.Out7].name}`,
|
||||
label: outputStates[OutputChannel.Out7].name,
|
||||
icon: <TextIcon>Out 7</TextIcon>,
|
||||
},
|
||||
{
|
||||
id: 'out8',
|
||||
label: `${outputStates[OutputChannel.Out8].name}`,
|
||||
label: outputStates[OutputChannel.Out8].name,
|
||||
icon: <TextIcon>Out 8</TextIcon>,
|
||||
},
|
||||
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
|
||||
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <BoltIcon /> },
|
||||
];
|
||||
|
||||
const presets = dsp.state.presets.names
|
||||
@@ -181,14 +198,12 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
||||
return (
|
||||
<MiscellaneousPanel
|
||||
dsp={dsp}
|
||||
onConnect={api.connect}
|
||||
onDisconnect={api.disconnect}
|
||||
factoryReset={api.factoryReset}
|
||||
copyChannel={api.copyChannel}
|
||||
setInputSource={api.setInputSource}
|
||||
linkChannels={api.linkChannels}
|
||||
unlockDevice={api.unlockDevice}
|
||||
lockDevice={api.lockDevice}
|
||||
setActiveSidebar={setActiveSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
.copy-channel-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: copy-channel-fade-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes copy-channel-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-channel-modal {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
|
||||
animation: copy-channel-scale-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes copy-channel-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-channel-modal__header {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.copy-channel-modal__header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copy-channel-modal__header p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Source -> destination */
|
||||
|
||||
.copy-channel-modal__transfer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.copy-channel-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copy-channel-modal__field label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: var(--card-control-height);
|
||||
color: var(--text-muted);
|
||||
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow svg {
|
||||
width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
}
|
||||
|
||||
.copy-channel-modal__summary {
|
||||
margin: var(--space-3) 0 var(--space-5);
|
||||
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Footer actions */
|
||||
|
||||
.copy-channel-modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.copy-channel-modal__transfer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.copy-channel-modal__arrow {
|
||||
height: auto;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Channel, InputChannel, OutputChannel } from '../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||
|
||||
import './CopyChannelModal.css';
|
||||
|
||||
const INPUT_CHANNELS: readonly string[] = Object.values(InputChannel);
|
||||
const OUTPUT_CHANNELS: readonly string[] = Object.values(OutputChannel);
|
||||
const ALL_CHANNELS: readonly string[] = [...INPUT_CHANNELS, ...OUTPUT_CHANNELS];
|
||||
|
||||
const isInputChannel = (raw: string) => INPUT_CHANNELS.includes(raw);
|
||||
|
||||
/** Wraps a raw enum string back into the { Input } / { Output } shape. */
|
||||
const wrapChannel = (raw: string): Channel =>
|
||||
isInputChannel(raw) ? { Input: raw as InputChannel } : { Output: raw as OutputChannel };
|
||||
|
||||
/** The channels a given source can be copied onto: same type, minus itself. */
|
||||
const destinationsFor = (source: string): string[] =>
|
||||
(isInputChannel(source) ? INPUT_CHANNELS : OUTPUT_CHANNELS).filter(
|
||||
(channel) => channel !== source
|
||||
);
|
||||
|
||||
function CopyChannelModal({
|
||||
onClose,
|
||||
copyChannel,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
||||
}) {
|
||||
const [source, setSource] = useState<string>(ALL_CHANNELS[0]);
|
||||
const [destination, setDestination] = useState<string>(() => destinationsFor(ALL_CHANNELS[0])[0]);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const destinationOptions = useMemo(() => destinationsFor(source), [source]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSourceChange = (nextSource: string) => {
|
||||
setSource(nextSource);
|
||||
|
||||
// Picking a source of the other type (or picking the current
|
||||
// destination as source) invalidates the destination.
|
||||
const nextDestinations = destinationsFor(nextSource);
|
||||
|
||||
if (!nextDestinations.includes(destination)) {
|
||||
setDestination(nextDestinations[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
setIsCopying(true);
|
||||
|
||||
try {
|
||||
await copyChannel(wrapChannel(source), wrapChannel(destination));
|
||||
onClose();
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="copy-channel-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="copy-channel-modal">
|
||||
<div className="copy-channel-modal__header">
|
||||
<h2>Copy Channel</h2>
|
||||
<p>
|
||||
Copy every setting of one channel onto another. Channels can only be copied onto
|
||||
channels of the same type.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="copy-channel-modal__transfer">
|
||||
<div className="copy-channel-modal__field">
|
||||
<label htmlFor="copy-channel-source">From</label>
|
||||
|
||||
<ChoiceSelect<string>
|
||||
id="copy-channel-source"
|
||||
value={source}
|
||||
options={ALL_CHANNELS}
|
||||
onChange={handleSourceChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="copy-channel-modal__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="copy-channel-modal__field">
|
||||
<label htmlFor="copy-channel-destination">To</label>
|
||||
|
||||
<ChoiceSelect<string>
|
||||
id="copy-channel-destination"
|
||||
value={destination}
|
||||
options={destinationOptions}
|
||||
onChange={setDestination}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="copy-channel-modal__summary">
|
||||
{destination} will take the settings of {source}.
|
||||
<br />
|
||||
This overwrites {destination}.
|
||||
</p>
|
||||
|
||||
<div className="copy-channel-modal__actions">
|
||||
<Button variant="outline" onClick={onClose} disabled={isCopying}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleCopy} disabled={isCopying}>
|
||||
{isCopying ? 'Copying...' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyChannelModal;
|
||||
@@ -0,0 +1,211 @@
|
||||
.link-channels-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: var(--space-5);
|
||||
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
animation: link-channels-fade-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes link-channels-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.link-channels-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
animation: link-channels-scale-in var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@keyframes link-channels-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.link-channels-modal__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.link-channels-modal__header h2 {
|
||||
margin: 0;
|
||||
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.link-channels-modal__header p {
|
||||
margin: var(--space-1) 0 0;
|
||||
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mode toggle */
|
||||
|
||||
.link-channels-modal__mode-toggle {
|
||||
display: inline-flex;
|
||||
|
||||
padding: var(--space-1);
|
||||
margin-bottom: var(--space-5);
|
||||
|
||||
background: var(--bg-panel);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.link-channels-modal__mode-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.link-channels-modal__mode-btn.is-active {
|
||||
background: var(--accent-brand);
|
||||
color: var(--bg-panel);
|
||||
}
|
||||
|
||||
/* Triangular link matrix */
|
||||
|
||||
.link-matrix {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.link-matrix__corner {
|
||||
width: var(--space-6);
|
||||
}
|
||||
|
||||
.link-matrix__col-label,
|
||||
.link-matrix__row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-matrix__row-label {
|
||||
justify-content: flex-start;
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.link-matrix__row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.link-matrix__cell {
|
||||
aspect-ratio: 1 / 1;
|
||||
min-width: 32px;
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.link-matrix__cell--empty {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.link-matrix__cell--toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
background: var(--bg-panel);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-standard),
|
||||
border-color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.link-matrix__cell--toggle:hover {
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.link-matrix__cell--toggle.is-active {
|
||||
background: var(--accent-brand);
|
||||
border-color: var(--accent-brand);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link-matrix__cell--toggle:disabled:hover {
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
/* A row whose channel is itself following another channel can't also
|
||||
lead others - its other tiles are locked rather than just inactive. */
|
||||
|
||||
.link-matrix__cell--unavailable {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--border-hairline),
|
||||
var(--border-hairline) 2px,
|
||||
transparent 2px,
|
||||
transparent 6px
|
||||
);
|
||||
border-color: var(--border-hairline);
|
||||
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.link-matrix__cell--unavailable:hover {
|
||||
border-color: var(--border-hairline);
|
||||
}
|
||||
|
||||
/* Footer actions */
|
||||
|
||||
.link-channels-modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.link-matrix__cell {
|
||||
min-width: 26px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DSP408 } from '../../../../types';
|
||||
import { Channel, InputChannel, OutputChannel } from '../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
|
||||
import './LinkChannelsModal.css';
|
||||
|
||||
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
|
||||
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
|
||||
|
||||
type LinkMode = 'input' | 'output';
|
||||
|
||||
type LinkGroup = {
|
||||
source: Channel;
|
||||
destinations: Channel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps every channel (by its raw enum string) to the channel it currently
|
||||
* follows. A channel that follows itself is unlinked. Several channels can
|
||||
* follow the same leader (many-to-one), but a channel that is itself
|
||||
* following someone can't also be followed by others - no chains.
|
||||
*/
|
||||
type LeaderMap = Record<string, string>;
|
||||
|
||||
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
|
||||
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
|
||||
|
||||
/**
|
||||
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
|
||||
* The device state has been observed to store these as plain strings
|
||||
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
|
||||
* shape used elsewhere, so this accepts either form defensively.
|
||||
*/
|
||||
function channelRawId(channel: Channel | string): string {
|
||||
if (typeof channel === 'string') {
|
||||
return channel;
|
||||
}
|
||||
|
||||
return 'Input' in channel ? channel.Input : channel.Output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks any chains that might be present in a leader map (a channel that
|
||||
* both follows someone and is followed by others) by releasing whoever
|
||||
* follows a follower back to themselves. Keeps the invariant - a channel
|
||||
* can lead or follow, never both - true regardless of what the device
|
||||
* reported.
|
||||
*/
|
||||
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
|
||||
const next = { ...leaderOf };
|
||||
|
||||
Object.keys(next).forEach((channel) => {
|
||||
const leader = next[channel];
|
||||
|
||||
if (leader !== channel && next[leader] !== leader) {
|
||||
// `channel` follows a leader that itself follows someone else -
|
||||
// release `channel` back to itself to break the chain.
|
||||
next[channel] = channel;
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
|
||||
* and turns it into a leader map, so the modal opens already reflecting the
|
||||
* device. Every channel defaults to following itself (unlinked); channels
|
||||
* reported as linked follow whichever channel the device reported first.
|
||||
*/
|
||||
function extractLeaderMap<T extends string>(
|
||||
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
|
||||
orderedChannels: readonly T[]
|
||||
): LeaderMap {
|
||||
const leaderOf: LeaderMap = {};
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
leaderOf[channel] = channel;
|
||||
});
|
||||
|
||||
if (!states) {
|
||||
return leaderOf;
|
||||
}
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const state = states[channel];
|
||||
|
||||
if (!state || state.linked_channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaderId = channelRawId(state.linked_channels[0]);
|
||||
|
||||
if (leaderId !== channel) {
|
||||
leaderOf[channel] = leaderId;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizeLeaderMap(leaderOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a leader map into one linkChannels call per leader that has
|
||||
* followers (source = leader, destinations = its followers), plus one call
|
||||
* per still-unlinked, follower-less channel with no destinations. Channels
|
||||
* that are themselves following someone don't get their own call - they're
|
||||
* already covered by their leader's call.
|
||||
*/
|
||||
function buildLinkCalls(
|
||||
leaderOf: LeaderMap,
|
||||
orderedChannels: readonly string[],
|
||||
wrap: (raw: string) => Channel
|
||||
): LinkGroup[] {
|
||||
const followersByLeader = new Map<string, string[]>();
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const leader = leaderOf[channel];
|
||||
|
||||
if (leader === channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = followersByLeader.get(leader) ?? [];
|
||||
existing.push(channel);
|
||||
followersByLeader.set(leader, existing);
|
||||
});
|
||||
|
||||
const calls: LinkGroup[] = [];
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const followers = followersByLeader.get(channel);
|
||||
|
||||
if (followers) {
|
||||
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderOf[channel] === channel) {
|
||||
calls.push({ source: wrap(channel), destinations: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
function LinkChannelsModal({
|
||||
dsp,
|
||||
onClose,
|
||||
linkChannels,
|
||||
}: {
|
||||
dsp: DSP408;
|
||||
onClose: () => void;
|
||||
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<LinkMode>('input');
|
||||
|
||||
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
|
||||
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
|
||||
);
|
||||
|
||||
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
|
||||
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
|
||||
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
|
||||
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const followChannel = (follower: string, leader: string) => {
|
||||
setLeaderOf((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
// `follower` can no longer lead anyone once it follows `leader` -
|
||||
// release whoever currently follows `follower` back to themselves.
|
||||
Object.keys(next).forEach((channel) => {
|
||||
if (next[channel] === follower) {
|
||||
next[channel] = channel;
|
||||
}
|
||||
});
|
||||
|
||||
next[follower] = leader;
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const unlinkChannel = (channel: string) => {
|
||||
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
|
||||
};
|
||||
|
||||
const handleCellClick = (row: string, col: string) => {
|
||||
const isSelf = row === col;
|
||||
|
||||
if (isSelf) {
|
||||
if (leaderOf[row] !== row) {
|
||||
unlinkChannel(row);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderOf[row] !== row || leaderOf[col] === row) {
|
||||
// Row is locked (it's itself following someone) or this pair is
|
||||
// already the current state - nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
followChannel(col, row);
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const groups = [
|
||||
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
|
||||
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
|
||||
];
|
||||
|
||||
for (const group of groups) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await linkChannels(group.source, group.destinations);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-channels-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="link-channels-modal">
|
||||
<div className="link-channels-modal__header">
|
||||
<h2>Link Channels</h2>
|
||||
<p>
|
||||
Link one or more channels to a single channel. A channel not linked to another is linked
|
||||
to itself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'input'}
|
||||
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('input')}
|
||||
>
|
||||
Input
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'output'}
|
||||
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('output')}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-matrix"
|
||||
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
|
||||
>
|
||||
<div className="link-matrix__corner" />
|
||||
|
||||
{channels.map((col) => (
|
||||
<div className="link-matrix__col-label" key={`col-${col}`}>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{channels.map((row, rowIndex) => {
|
||||
const isRowLocked = leaderOf[row] !== row;
|
||||
|
||||
return (
|
||||
<div className="link-matrix__row" key={`row-${row}`}>
|
||||
<div className="link-matrix__row-label">{row}</div>
|
||||
|
||||
{channels.map((col, colIndex) => {
|
||||
if (colIndex < rowIndex) {
|
||||
return (
|
||||
<div
|
||||
className="link-matrix__cell link-matrix__cell--empty"
|
||||
key={`cell-${row}-${col}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelf = row === col;
|
||||
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
|
||||
const isUnavailable = !isSelf && !isActive && isRowLocked;
|
||||
|
||||
const label = isSelf
|
||||
? isActive
|
||||
? `${row} not linked`
|
||||
: `Unlink ${row}`
|
||||
: isUnavailable
|
||||
? `${row} is already linked to another channel`
|
||||
: isActive
|
||||
? `${col} linked to ${row}`
|
||||
: `Link ${col} to ${row}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`cell-${row}-${col}`}
|
||||
className={[
|
||||
'link-matrix__cell',
|
||||
'link-matrix__cell--toggle',
|
||||
isUnavailable && 'link-matrix__cell--unavailable',
|
||||
isActive && 'is-active',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={isActive}
|
||||
disabled={isActive || isUnavailable}
|
||||
aria-label={label}
|
||||
onClick={() => handleCellClick(row, col)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="link-channels-modal__actions">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
|
||||
{isSaving ? 'Linking...' : 'Validate'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkChannelsModal;
|
||||
@@ -1,110 +1,260 @@
|
||||
.connection-section {
|
||||
.miscellaneous-section {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
width: 340px;
|
||||
.miscellaneous-panel {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
/* margin: 0 auto; */
|
||||
}
|
||||
|
||||
.miscellaneous-panel__header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__hint {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
|
||||
.miscellaneous-panel__section {
|
||||
padding-top: var(--space-5);
|
||||
margin-top: var(--space-5);
|
||||
|
||||
border-top: var(--border-width) solid var(--border-hairline);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__section:first-of-type {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.miscellaneous-panel__section-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__section-header h2 {
|
||||
margin: 0;
|
||||
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__section-header p {
|
||||
margin: var(--space-1) 0 0;
|
||||
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
|
||||
.miscellaneous-panel__row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
gap: var(--space-2);
|
||||
|
||||
.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);
|
||||
|
||||
.miscellaneous-panel__field label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.connection-panel__specs {
|
||||
margin: var(--space-5) 0;
|
||||
.miscellaneous-panel__field input {
|
||||
height: var(--card-control-height);
|
||||
padding: 0 var(--space-3);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.connection-panel__spec {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.connection-panel__spec dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.connection-panel__spec dd {
|
||||
margin: 0;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-sm);
|
||||
|
||||
outline: none;
|
||||
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
transition: border-color var(--dur-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.connection-panel__mono {
|
||||
.miscellaneous-panel__field input.mono {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.connection-panel__actions {
|
||||
margin-top: auto;
|
||||
.miscellaneous-panel__field input:focus {
|
||||
border-color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__field input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Stepper */
|
||||
|
||||
.miscellaneous-panel__field .choice-stepper__input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
.miscellaneous-panel__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.connection-panel__status {
|
||||
/* Danger */
|
||||
|
||||
.miscellaneous-panel__section--danger {
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__section--danger .miscellaneous-panel__section-header p {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.miscellaneous-panel__row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.miscellaneous-panel__actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.miscellaneous-panel {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.miscellaneous-panel input,
|
||||
.miscellaneous-panel select {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.confirm-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-content: center;
|
||||
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.connection-panel__led {
|
||||
height: 14px;
|
||||
aspect-ratio: 1 / 1;
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: var(--space-5);
|
||||
margin: var(--space-4);
|
||||
|
||||
background: var(--bg-panel);
|
||||
border: var(--border-width) solid var(--border-hairline);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.confirm-modal h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.confirm-modal p {
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.confirm-modal__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
/* Password Input */
|
||||
|
||||
.miscellaneous-panel__password-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.miscellaneous-panel__password-wrap input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.miscellaneous-panel__password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 3%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent-brand);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.miscellaneous-panel__password-toggle:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.miscellaneous-panel__password-toggle svg {
|
||||
width: auto;
|
||||
height: 65%;
|
||||
}
|
||||
|
||||
@@ -1,52 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DSP } from '../../../../types';
|
||||
import { Channel, InputSource } from '../../types';
|
||||
import { DSP408 } from '../../../../types';
|
||||
import { Channel, InputSource, InputType, DiscreteFrequency } from '../../types';
|
||||
import { SidebarId } from '../../constants';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
import Led from '../../../../components/Led/Led';
|
||||
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||
import LinkChannelsModal from './LinkChannelsModal';
|
||||
import CopyChannelModal from './CopyChannelModal';
|
||||
|
||||
import './MiscellaneousSection.css';
|
||||
import { EyeIcon, EyeOffIcon } from '../../../../assets/icons/EyesIcons';
|
||||
|
||||
const INPUT_TYPES: readonly InputType[] = Object.values(InputType);
|
||||
const DISCRETE_FREQUENCIES: readonly DiscreteFrequency[] = Object.values(DiscreteFrequency);
|
||||
|
||||
function MiscellaneousPanel({
|
||||
dsp,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
factoryReset,
|
||||
copyChannel,
|
||||
setInputSource,
|
||||
linkChannels,
|
||||
unlockDevice,
|
||||
lockDevice,
|
||||
setActiveSidebar,
|
||||
}: {
|
||||
dsp: DSP;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
dsp: DSP408;
|
||||
factoryReset: (force: boolean) => Promise<boolean>;
|
||||
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
||||
setInputSource: (inputSource: InputSource) => Promise<boolean>;
|
||||
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||
unlockDevice: (password: string) => Promise<boolean>;
|
||||
lockDevice: (password: string) => Promise<boolean>;
|
||||
setActiveSidebar: (id: SidebarId) => void;
|
||||
}) {
|
||||
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
|
||||
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
|
||||
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
|
||||
|
||||
const statusLabel =
|
||||
dsp.status === 'connected'
|
||||
? 'Connected'
|
||||
: dsp.status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: 'Disconnected';
|
||||
const [inputSource, setInputSourceState] = useState<InputSource>({
|
||||
type: InputType.Analog,
|
||||
frequency: DiscreteFrequency.F20,
|
||||
});
|
||||
|
||||
const handleFactoryReset = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to factory reset this DSP? This cannot be undone.',
|
||||
);
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const handleInputTypeChange = async (type: InputType) => {
|
||||
const nextSource: InputSource = {
|
||||
type,
|
||||
frequency: type === InputType.SineWave ? inputSource.frequency : DiscreteFrequency.F20,
|
||||
};
|
||||
|
||||
setInputSourceState(nextSource);
|
||||
await setInputSource(nextSource);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = async (frequency: DiscreteFrequency) => {
|
||||
const nextSource: InputSource = {
|
||||
...inputSource,
|
||||
frequency,
|
||||
};
|
||||
|
||||
setInputSourceState(nextSource);
|
||||
await setInputSource(nextSource);
|
||||
};
|
||||
|
||||
const handleFactoryReset = () => {
|
||||
setIsResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmFactoryReset = async () => {
|
||||
setIsResetConfirmOpen(false);
|
||||
setIsFactoryResetting(true);
|
||||
|
||||
try {
|
||||
@@ -56,85 +80,175 @@ function MiscellaneousPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await lockDevice(password);
|
||||
|
||||
if (success) {
|
||||
setPassword('');
|
||||
setActiveSidebar('overview');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="connection-section">
|
||||
<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="connection-panel__type">{dsp.type}</div>
|
||||
<div className="miscellaneous-section">
|
||||
<Card className="miscellaneous-panel">
|
||||
{/* Input Source */}
|
||||
<section className="miscellaneous-panel__section">
|
||||
<div className="miscellaneous-panel__section-header">
|
||||
<h2>Input Source</h2>
|
||||
<p>Select the signal source used by the DSP.</p>
|
||||
</div>
|
||||
|
||||
<div className="connection-panel__status">
|
||||
<span className="connection-panel__led">
|
||||
<Led status={dsp.status} />
|
||||
</span>
|
||||
<div className="miscellaneous-panel__row">
|
||||
<div className="miscellaneous-panel__field">
|
||||
<label htmlFor="misc-input-type">Source</label>
|
||||
|
||||
<span
|
||||
className={`status-label status-label--${dsp.status}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<ChoiceSelect<InputType>
|
||||
id="misc-input-type"
|
||||
value={inputSource.type}
|
||||
options={INPUT_TYPES}
|
||||
onChange={handleInputTypeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputSource.type === InputType.SineWave && (
|
||||
<div className="miscellaneous-panel__field">
|
||||
<label htmlFor="misc-frequency">Frequency</label>
|
||||
|
||||
<ChoiceSelect<DiscreteFrequency>
|
||||
id="misc-frequency"
|
||||
value={inputSource.frequency}
|
||||
options={DISCRETE_FREQUENCIES}
|
||||
formatLabel={(frequency) => {
|
||||
if (frequency === DiscreteFrequency.F31_5) {
|
||||
return '31.5 Hz';
|
||||
}
|
||||
|
||||
return `${frequency.slice(1)} Hz`;
|
||||
}}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{/* Channel Tools */}
|
||||
<section className="miscellaneous-panel__section">
|
||||
<div className="miscellaneous-panel__section-header">
|
||||
<h2>Channel Tools</h2>
|
||||
<p>Copy settings between channels or link multiple channels.</p>
|
||||
</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
|
||||
<div className="miscellaneous-panel__actions">
|
||||
<Button variant="outline" onClick={() => setIsCopyModalOpen(true)}>
|
||||
Copy Channel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConnect}
|
||||
disabled={dsp.status === 'connecting'}
|
||||
>
|
||||
{dsp.status === 'connecting' ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="miscellaneous-panel__actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFactoryReset}
|
||||
disabled={
|
||||
dsp.status !== 'connected' || isFactoryResetting
|
||||
}
|
||||
>
|
||||
{isFactoryResetting ? 'Resetting…' : 'Factory Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setIsLinkModalOpen(true)}>
|
||||
Link Channels
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<section className="miscellaneous-panel__section">
|
||||
<div className="miscellaneous-panel__section-header">
|
||||
<h2>Device Security</h2>
|
||||
<p>Lock access to the DSP.</p>
|
||||
</div>
|
||||
|
||||
<div className="miscellaneous-panel__field">
|
||||
<label htmlFor="misc-password">Password</label>
|
||||
|
||||
<div className="miscellaneous-panel__password-wrap">
|
||||
<input
|
||||
id="misc-password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
maxLength={4}
|
||||
value={password}
|
||||
placeholder="Enter password"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (/^[\x20-\x7E]*$/.test(value)) {
|
||||
setPassword(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="miscellaneous-panel__password-toggle"
|
||||
onClick={() => setIsPasswordVisible((v) => !v)}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
aria-pressed={isPasswordVisible}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="miscellaneous-panel__actions">
|
||||
<Button variant="outline" onClick={handleLock} disabled={password.length !== 4}>
|
||||
Lock Device
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Factory Reset */}
|
||||
<section className="miscellaneous-panel__section miscellaneous-panel__section--danger">
|
||||
<div className="miscellaneous-panel__section-header">
|
||||
<h2>Factory Reset</h2>
|
||||
<p>Restore the DSP to its factory configuration. This cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div className="miscellaneous-panel__actions">
|
||||
<Button variant="outline" onClick={handleFactoryReset} disabled={isFactoryResetting}>
|
||||
{isFactoryResetting ? 'Resetting...' : 'Factory Reset'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{isLinkModalOpen && (
|
||||
<LinkChannelsModal
|
||||
dsp={dsp}
|
||||
onClose={() => setIsLinkModalOpen(false)}
|
||||
linkChannels={linkChannels}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCopyModalOpen && (
|
||||
<CopyChannelModal onClose={() => setIsCopyModalOpen(false)} copyChannel={copyChannel} />
|
||||
)}
|
||||
|
||||
{isResetConfirmOpen && (
|
||||
<div className="confirm-modal-overlay" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Factory Reset DSP?</h3>
|
||||
<p>
|
||||
This will restore the DSP to its factory configuration. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="confirm-modal__actions">
|
||||
<Button variant="outline" onClick={() => setIsResetConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmFactoryReset}>
|
||||
Factory Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MiscellaneousPanel;
|
||||
export default MiscellaneousPanel;
|
||||
|
||||
@@ -183,9 +183,16 @@ export type DeviceFlags = {
|
||||
is_locked: boolean;
|
||||
};
|
||||
|
||||
export enum InputType {
|
||||
Analog = 'Analog',
|
||||
PinkNoise = 'PinkNoise',
|
||||
WhiteNoise = 'WhiteNoise',
|
||||
SineWave = 'SineWave',
|
||||
}
|
||||
|
||||
export type InputSource = {
|
||||
type: string;
|
||||
frequency: string;
|
||||
type: InputType;
|
||||
frequency: DiscreteFrequency;
|
||||
};
|
||||
|
||||
export type InputChannelState = {
|
||||
|
||||
@@ -52,8 +52,6 @@ export type DSP408 = DSPBase & {
|
||||
|
||||
export type DSP204 = DSPBase & {
|
||||
type: 'DSP204';
|
||||
state: null;
|
||||
meters: null;
|
||||
};
|
||||
|
||||
export type DSP = DSP408 | DSP204;
|
||||
|
||||
Reference in New Issue
Block a user