Files
dsp-thomann-interface/src/features/dsp408/components/MiscellaneousSection/LinkChannelsModal.tsx
T

369 lines
11 KiB
TypeScript

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;