187 lines
8.3 KiB
TypeScript
187 lines
8.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
|
|
import {
|
|
GetIcomState, IcomRefresh,
|
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
|
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
|
} from '../../wailsjs/go/main/App';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
type IcomState = {
|
|
available: boolean; model?: string; mode?: string;
|
|
af_gain: number; rf_gain: number;
|
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
|
agc?: string; preamp: number; att: number; filter: number;
|
|
};
|
|
|
|
const ZERO: IcomState = {
|
|
available: false, af_gain: 0, rf_gain: 0,
|
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
|
preamp: 0, att: 0, filter: 1,
|
|
};
|
|
|
|
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
|
}) {
|
|
const v = Math.max(0, Math.min(100, value));
|
|
return (
|
|
<input
|
|
type="range" min={0} max={100} value={v} disabled={disabled}
|
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
|
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Segmented({ value, options, onChange }: {
|
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
|
{options.map((o) => (
|
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
|
{o.l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
|
return (
|
|
<button type="button" onClick={onClick}
|
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
|
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Chip on={on} onClick={onToggle} label={label} />
|
|
<Slider value={value} disabled={!on} onChange={onLevel} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
|
</div>
|
|
<div className="p-3 space-y-3">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
|
|
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
|
|
// the last refresh plus optimistic updates. Front-panel knob changes show after
|
|
// the next ↻ Refresh.
|
|
export function IcomPanel() {
|
|
const [st, setSt] = useState<IcomState>(ZERO);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
|
const refresh = async () => {
|
|
setBusy(true);
|
|
try { await IcomRefresh(); } catch {}
|
|
await load();
|
|
setBusy(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
|
|
return () => window.clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Optimistic local update + fire the command; the cache poll reconciles.
|
|
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
|
setSt((s) => ({ ...s, ...patch }));
|
|
fn().catch(() => {});
|
|
};
|
|
|
|
if (!st.available) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
|
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
|
|
<button type="button" onClick={refresh} disabled={busy}
|
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<Card icon={Radio} title="Receive" accent="#2563eb">
|
|
<Row label="AF">
|
|
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
|
</Row>
|
|
<Row label="RF">
|
|
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
|
</Row>
|
|
<Row label="AGC">
|
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
|
</Row>
|
|
<Row label="Preamp">
|
|
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
|
</Row>
|
|
<Row label="Att">
|
|
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
|
</Row>
|
|
<Row label="Filter">
|
|
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
|
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
|
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
|
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
|
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
|
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
|
<div className="flex items-center gap-2">
|
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
|
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|