diff --git a/README.md b/README.md index 985bc0e..28fbd0a 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7806ce2..94ee924 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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>, diff --git a/src/assets/icons/AudioIcons.tsx b/src/assets/icons/AudioIcons.tsx index 7480b1f..8d15471 100644 --- a/src/assets/icons/AudioIcons.tsx +++ b/src/assets/icons/AudioIcons.tsx @@ -84,3 +84,188 @@ export function TextIcon({ ); } + +export function SlidersVerticalIcon(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + ); +} + +export function BoltIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function GainIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function GateIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function CompressorIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function LimiterIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function DelayIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function MatrixIcon(props: React.SVGProps) { + return ( + + + + + + + + + + + ); +} diff --git a/src/assets/icons/EyesIcons.tsx b/src/assets/icons/EyesIcons.tsx new file mode 100644 index 0000000..396b0b3 --- /dev/null +++ b/src/assets/icons/EyesIcons.tsx @@ -0,0 +1,42 @@ +export function EyeIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function EyeOffIcon(props: React.SVGProps) { + return ( + + + + + + ); +} diff --git a/src/components/ChoiceSelect/ChoiceSelect.css b/src/components/ChoiceSelect/ChoiceSelect.css index 0e934ff..97d5524 100644 --- a/src/components/ChoiceSelect/ChoiceSelect.css +++ b/src/components/ChoiceSelect/ChoiceSelect.css @@ -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; } diff --git a/src/components/Topbar/Topbar.tsx b/src/components/Topbar/Topbar.tsx index 57a9a52..ea2a02b 100644 --- a/src/components/Topbar/Topbar.tsx +++ b/src/components/Topbar/Topbar.tsx @@ -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} + {actions} + + + + + + + ); +} + +export default CopyChannelModal; diff --git a/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.css b/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.css new file mode 100644 index 0000000..9ffb467 --- /dev/null +++ b/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.css @@ -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; + } +} diff --git a/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.tsx b/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.tsx new file mode 100644 index 0000000..696843b --- /dev/null +++ b/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.tsx @@ -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; + +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( + states: Partial> | 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(); + + 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; +}) { + const [mode, setMode] = useState('input'); + + const [inputLeaders, setInputLeaders] = useState(() => + extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS) + ); + + const [outputLeaders, setOutputLeaders] = useState(() => + 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 ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > + +
+

Link Channels

+

+ Link one or more channels to a single channel. A channel not linked to another is linked + to itself. +

+
+ +
+ + + +
+ +
+
+ + {channels.map((col) => ( +
+ {col} +
+ ))} + + {channels.map((row, rowIndex) => { + const isRowLocked = leaderOf[row] !== row; + + return ( +
+
{row}
+ + {channels.map((col, colIndex) => { + if (colIndex < rowIndex) { + return ( +
+ ); + } + + 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 ( +
+ ); + })} +
+ +
+ + + +
+ +
+ ); +} + +export default LinkChannelsModal; diff --git a/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.css b/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.css index e6595bf..681ea13 100644 --- a/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.css +++ b/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.css @@ -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%; } diff --git a/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.tsx b/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.tsx index d879dcd..64c28e3 100644 --- a/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.tsx +++ b/src/features/dsp408/components/MiscellaneousSection/MiscellaneousSection.tsx @@ -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; copyChannel: (channel1: Channel, channel2: Channel) => Promise; setInputSource: (inputSource: InputSource) => Promise; linkChannels: (source: Channel, destinations: Channel[]) => Promise; - unlockDevice: (password: string) => Promise; lockDevice: (password: string) => Promise; + 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({ + 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 ( -
- -
-
-

- {dsp.name} -

- -
{dsp.type}
+
+ + {/* Input Source */} +
+
+

Input Source

+

Select the signal source used by the DSP.

-
- - - +
+
+ - - {statusLabel} - + + id="misc-input-type" + value={inputSource.type} + options={INPUT_TYPES} + onChange={handleInputTypeChange} + /> +
+ + {inputSource.type === InputType.SineWave && ( +
+ + + + 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} + /> +
+ )}
-
+
-
-
-
IP address
-
- {dsp.ip} -
+ {/* Channel Tools */} +
+
+

Channel Tools

+

Copy settings between channels or link multiple channels.

-
-
Port
-
{dsp.port}
-
- -
-
Device ID
-
- {dsp.deviceId || '—'} -
-
-
- -
- {dsp.status === 'connected' ? ( - - ) : ( - - )} -
-
- -
+ +
+ + + {/* Security */} +
+
+

Device Security

+

Lock access to the DSP.

+
+ +
+ + +
+ { + const value = e.target.value; + + if (/^[\x20-\x7E]*$/.test(value)) { + setPassword(value); + } + }} + /> + + +
+
+ +
+ +
+
+ + {/* Factory Reset */} +
+
+

Factory Reset

+

Restore the DSP to its factory configuration. This cannot be undone.

+
+ +
+ +
+
+ + {isLinkModalOpen && ( + setIsLinkModalOpen(false)} + linkChannels={linkChannels} + /> + )} + + {isCopyModalOpen && ( + setIsCopyModalOpen(false)} copyChannel={copyChannel} /> + )} + + {isResetConfirmOpen && ( +
setIsResetConfirmOpen(false)}> +
e.stopPropagation()}> +

Factory Reset DSP?

+

+ This will restore the DSP to its factory configuration. This action cannot be undone. +

+ +
+ + +
+
+
+ )}
); } -export default MiscellaneousPanel; \ No newline at end of file +export default MiscellaneousPanel; diff --git a/src/features/dsp408/types.ts b/src/features/dsp408/types.ts index a25170c..d7432b4 100644 --- a/src/features/dsp408/types.ts +++ b/src/features/dsp408/types.ts @@ -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 = { diff --git a/src/types.ts b/src/types.ts index a35e701..911abc2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,8 +52,6 @@ export type DSP408 = DSPBase & { export type DSP204 = DSPBase & { type: 'DSP204'; - state: null; - meters: null; }; export type DSP = DSP408 | DSP204;