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
|
# TODO
|
||||||
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
|
- 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]
|
#[tauri::command]
|
||||||
pub async fn factory_reset(
|
pub async fn factory_reset(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -207,7 +207,7 @@ pub async fn set_current_preset_name(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x2A` -- TODO
|
// Command `0x2A`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn copy_channel(
|
pub async fn copy_channel(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -238,7 +238,7 @@ pub async fn unlock_device(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x2F` -- TODO
|
// Command `0x2F`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn lock_device(
|
pub async fn lock_device(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -399,7 +399,7 @@ pub async fn set_delay(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command `0x39` -- TODO
|
// Command `0x39`
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_input_source(
|
pub async fn set_input_source(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|||||||
@@ -84,3 +84,188 @@ export function TextIcon({
|
|||||||
</svg>
|
</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 {
|
.choice-select__chevron {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 5%;
|
right: 2%;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
||||||
width: 4cqh;
|
width: 4cqh;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.choice-select__chevron svg {
|
.choice-select__chevron svg {
|
||||||
width: 100%;
|
width: 75%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function Tab({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -33,6 +34,7 @@ function Tab({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onConnect: (dsp: DSP) => void;
|
onConnect: (dsp: DSP) => void;
|
||||||
onDisconnect: (dsp: DSP) => void;
|
onDisconnect: (dsp: DSP) => void;
|
||||||
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const isConnected = dsp.status === 'connected';
|
const isConnected = dsp.status === 'connected';
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ function Tab({
|
|||||||
{dsp.name}
|
{dsp.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tab-close"
|
className="tab-close"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sidebar, SidebarItem } from '../../../../components/Sidebar/Sidebar';
|
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 ConnectionPanel from '../ConnectionSection/ConnectionSection';
|
||||||
import GainSection from '../GainSection/GainSection';
|
import GainSection from '../GainSection/GainSection';
|
||||||
import CompressorSection from '../CompressorSection/CompressorSection';
|
import CompressorSection from '../CompressorSection/CompressorSection';
|
||||||
@@ -23,63 +23,80 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||||||
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
|
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
|
||||||
const [presetLoading, setPresetLoading] = useState(false);
|
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>[] = [
|
const sidebarItems: SidebarItem<SidebarId>[] = [
|
||||||
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
|
||||||
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
|
{ id: 'gain', label: 'Gain', icon: <GainIcon /> },
|
||||||
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
|
{ id: 'gate', label: 'Gate', icon: <GateIcon /> },
|
||||||
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
|
{ id: 'compressor', label: 'Compressor', icon: <CompressorIcon /> },
|
||||||
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
|
{ id: 'limiter', label: 'Limiter', icon: <LimiterIcon /> },
|
||||||
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
|
{ id: 'delay', label: 'Delay', icon: <DelayIcon /> },
|
||||||
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
|
{ id: 'matrix', label: 'Matrix', icon: <MatrixIcon /> },
|
||||||
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
|
{ 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: 'ina',
|
||||||
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
|
label: inputStates[InputChannel.InA].name,
|
||||||
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
|
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',
|
id: 'out1',
|
||||||
label: `${outputStates[OutputChannel.Out1].name}`,
|
label: outputStates[OutputChannel.Out1].name,
|
||||||
icon: <TextIcon>Out 1</TextIcon>,
|
icon: <TextIcon>Out 1</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out2',
|
id: 'out2',
|
||||||
label: `${outputStates[OutputChannel.Out2].name}`,
|
label: outputStates[OutputChannel.Out2].name,
|
||||||
icon: <TextIcon>Out 2</TextIcon>,
|
icon: <TextIcon>Out 2</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out3',
|
id: 'out3',
|
||||||
label: `${outputStates[OutputChannel.Out3].name}`,
|
label: outputStates[OutputChannel.Out3].name,
|
||||||
icon: <TextIcon>Out 3</TextIcon>,
|
icon: <TextIcon>Out 3</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out4',
|
id: 'out4',
|
||||||
label: `${outputStates[OutputChannel.Out4].name}`,
|
label: outputStates[OutputChannel.Out4].name,
|
||||||
icon: <TextIcon>Out 4</TextIcon>,
|
icon: <TextIcon>Out 4</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out5',
|
id: 'out5',
|
||||||
label: `${outputStates[OutputChannel.Out5].name}`,
|
label: outputStates[OutputChannel.Out5].name,
|
||||||
icon: <TextIcon>Out 5</TextIcon>,
|
icon: <TextIcon>Out 5</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out6',
|
id: 'out6',
|
||||||
label: `${outputStates[OutputChannel.Out6].name}`,
|
label: outputStates[OutputChannel.Out6].name,
|
||||||
icon: <TextIcon>Out 6</TextIcon>,
|
icon: <TextIcon>Out 6</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out7',
|
id: 'out7',
|
||||||
label: `${outputStates[OutputChannel.Out7].name}`,
|
label: outputStates[OutputChannel.Out7].name,
|
||||||
icon: <TextIcon>Out 7</TextIcon>,
|
icon: <TextIcon>Out 7</TextIcon>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'out8',
|
id: 'out8',
|
||||||
label: `${outputStates[OutputChannel.Out8].name}`,
|
label: outputStates[OutputChannel.Out8].name,
|
||||||
icon: <TextIcon>Out 8</TextIcon>,
|
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
|
const presets = dsp.state.presets.names
|
||||||
@@ -181,14 +198,12 @@ function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
|
|||||||
return (
|
return (
|
||||||
<MiscellaneousPanel
|
<MiscellaneousPanel
|
||||||
dsp={dsp}
|
dsp={dsp}
|
||||||
onConnect={api.connect}
|
|
||||||
onDisconnect={api.disconnect}
|
|
||||||
factoryReset={api.factoryReset}
|
factoryReset={api.factoryReset}
|
||||||
copyChannel={api.copyChannel}
|
copyChannel={api.copyChannel}
|
||||||
setInputSource={api.setInputSource}
|
setInputSource={api.setInputSource}
|
||||||
linkChannels={api.linkChannels}
|
linkChannels={api.linkChannels}
|
||||||
unlockDevice={api.unlockDevice}
|
|
||||||
lockDevice={api.lockDevice}
|
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%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-panel {
|
.miscellaneous-panel {
|
||||||
width: 340px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-panel__title {
|
.miscellaneous-panel__field label {
|
||||||
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: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-panel__specs {
|
.miscellaneous-panel__field input {
|
||||||
margin: var(--space-5) 0;
|
height: var(--card-control-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
|
||||||
display: flex;
|
background: var(--bg-panel);
|
||||||
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;
|
|
||||||
color: var(--text-primary);
|
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);
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-panel__actions {
|
.miscellaneous-panel__field input:focus {
|
||||||
margin-top: auto;
|
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;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.confirm-modal {
|
||||||
height: 14px;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 { useState } from 'react';
|
||||||
|
|
||||||
import { DSP } from '../../../../types';
|
import { DSP408 } from '../../../../types';
|
||||||
import { Channel, InputSource } from '../../types';
|
import { Channel, InputSource, InputType, DiscreteFrequency } from '../../types';
|
||||||
|
import { SidebarId } from '../../constants';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
import Card from '../../../../components/Card/Card';
|
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 './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({
|
function MiscellaneousPanel({
|
||||||
dsp,
|
dsp,
|
||||||
onConnect,
|
|
||||||
onDisconnect,
|
|
||||||
factoryReset,
|
factoryReset,
|
||||||
copyChannel,
|
copyChannel,
|
||||||
setInputSource,
|
setInputSource,
|
||||||
linkChannels,
|
linkChannels,
|
||||||
unlockDevice,
|
|
||||||
lockDevice,
|
lockDevice,
|
||||||
|
setActiveSidebar,
|
||||||
}: {
|
}: {
|
||||||
dsp: DSP;
|
dsp: DSP408;
|
||||||
onConnect: () => void;
|
|
||||||
onDisconnect: () => void;
|
|
||||||
factoryReset: (force: boolean) => Promise<boolean>;
|
factoryReset: (force: boolean) => Promise<boolean>;
|
||||||
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
copyChannel: (channel1: Channel, channel2: Channel) => Promise<boolean>;
|
||||||
setInputSource: (inputSource: InputSource) => Promise<boolean>;
|
setInputSource: (inputSource: InputSource) => Promise<boolean>;
|
||||||
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||||
unlockDevice: (password: string) => Promise<boolean>;
|
|
||||||
lockDevice: (password: string) => Promise<boolean>;
|
lockDevice: (password: string) => Promise<boolean>;
|
||||||
|
setActiveSidebar: (id: SidebarId) => void;
|
||||||
}) {
|
}) {
|
||||||
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
|
const [isFactoryResetting, setIsFactoryResetting] = useState(false);
|
||||||
|
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||||
|
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
|
||||||
|
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
|
||||||
|
|
||||||
const statusLabel =
|
const [inputSource, setInputSourceState] = useState<InputSource>({
|
||||||
dsp.status === 'connected'
|
type: InputType.Analog,
|
||||||
? 'Connected'
|
frequency: DiscreteFrequency.F20,
|
||||||
: dsp.status === 'connecting'
|
});
|
||||||
? 'Connecting…'
|
|
||||||
: 'Disconnected';
|
|
||||||
|
|
||||||
const handleFactoryReset = async () => {
|
const [password, setPassword] = useState('');
|
||||||
const confirmed = window.confirm(
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
'Are you sure you want to factory reset this DSP? This cannot be undone.',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
const handleInputTypeChange = async (type: InputType) => {
|
||||||
return;
|
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);
|
setIsFactoryResetting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -56,85 +80,175 @@ function MiscellaneousPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLock = async () => {
|
||||||
|
if (!password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await lockDevice(password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setPassword('');
|
||||||
|
setActiveSidebar('overview');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="connection-section">
|
<div className="miscellaneous-section">
|
||||||
<Card className="connection-panel">
|
<Card className="miscellaneous-panel">
|
||||||
<div className="connection-panel__header">
|
{/* Input Source */}
|
||||||
<div className="connection-panel__header-text">
|
<section className="miscellaneous-panel__section">
|
||||||
<h1 className="connection-panel__title" title={dsp.name}>
|
<div className="miscellaneous-panel__section-header">
|
||||||
{dsp.name}
|
<h2>Input Source</h2>
|
||||||
</h1>
|
<p>Select the signal source used by the DSP.</p>
|
||||||
|
|
||||||
<div className="connection-panel__type">{dsp.type}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="connection-panel__status">
|
<div className="miscellaneous-panel__row">
|
||||||
<span className="connection-panel__led">
|
<div className="miscellaneous-panel__field">
|
||||||
<Led status={dsp.status} />
|
<label htmlFor="misc-input-type">Source</label>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
<ChoiceSelect<InputType>
|
||||||
className={`status-label status-label--${dsp.status}`}
|
id="misc-input-type"
|
||||||
>
|
value={inputSource.type}
|
||||||
{statusLabel}
|
options={INPUT_TYPES}
|
||||||
</span>
|
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>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<dl className="connection-panel__specs">
|
{/* Channel Tools */}
|
||||||
<div className="connection-panel__spec">
|
<section className="miscellaneous-panel__section">
|
||||||
<dt>IP address</dt>
|
<div className="miscellaneous-panel__section-header">
|
||||||
<dd className="connection-panel__mono" title={dsp.ip}>
|
<h2>Channel Tools</h2>
|
||||||
{dsp.ip}
|
<p>Copy settings between channels or link multiple channels.</p>
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="connection-panel__spec">
|
<div className="miscellaneous-panel__actions">
|
||||||
<dt>Port</dt>
|
<Button variant="outline" onClick={() => setIsCopyModalOpen(true)}>
|
||||||
<dd className="connection-panel__mono">{dsp.port}</dd>
|
Copy Channel
|
||||||
</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>
|
||||||
) : (
|
|
||||||
<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={() => setIsLinkModalOpen(true)}>
|
||||||
<Button
|
Link Channels
|
||||||
variant="outline"
|
</Button>
|
||||||
onClick={handleFactoryReset}
|
</div>
|
||||||
disabled={
|
</section>
|
||||||
dsp.status !== 'connected' || isFactoryResetting
|
|
||||||
}
|
{/* Security */}
|
||||||
>
|
<section className="miscellaneous-panel__section">
|
||||||
{isFactoryResetting ? 'Resetting…' : 'Factory Reset'}
|
<div className="miscellaneous-panel__section-header">
|
||||||
</Button>
|
<h2>Device Security</h2>
|
||||||
</div>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MiscellaneousPanel;
|
export default MiscellaneousPanel;
|
||||||
|
|||||||
@@ -183,9 +183,16 @@ export type DeviceFlags = {
|
|||||||
is_locked: boolean;
|
is_locked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum InputType {
|
||||||
|
Analog = 'Analog',
|
||||||
|
PinkNoise = 'PinkNoise',
|
||||||
|
WhiteNoise = 'WhiteNoise',
|
||||||
|
SineWave = 'SineWave',
|
||||||
|
}
|
||||||
|
|
||||||
export type InputSource = {
|
export type InputSource = {
|
||||||
type: string;
|
type: InputType;
|
||||||
frequency: string;
|
frequency: DiscreteFrequency;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InputChannelState = {
|
export type InputChannelState = {
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export type DSP408 = DSPBase & {
|
|||||||
|
|
||||||
export type DSP204 = DSPBase & {
|
export type DSP204 = DSPBase & {
|
||||||
type: 'DSP204';
|
type: 'DSP204';
|
||||||
state: null;
|
|
||||||
meters: null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DSP = DSP408 | DSP204;
|
export type DSP = DSP408 | DSP204;
|
||||||
|
|||||||
Reference in New Issue
Block a user