feat: added record qso dvk
This commit is contained in:
+80
-13
@@ -8,7 +8,7 @@ import {
|
||||
AddQSO, ListQSO, CountQSO,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UploadQSOsManual,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus,
|
||||
WorkedBefore,
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
QSOAudioBegin, QSOAudioCancel,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { EventsOn } from '../wailsjs/runtime/runtime';
|
||||
@@ -45,6 +47,7 @@ import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -495,6 +498,29 @@ export default function App() {
|
||||
const wkEscClearsRef = useRef(true);
|
||||
useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
|
||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||
|
||||
// === Digital Voice Keyer (DVK) ===
|
||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
const dvkActiveRef = useRef(false);
|
||||
const dvkPlayingRef = useRef(false);
|
||||
const dvkPlayRef = useRef<(slot: number) => void>(() => {});
|
||||
useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
|
||||
useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
|
||||
useEffect(() => {
|
||||
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
|
||||
return () => { off?.(); };
|
||||
}, []);
|
||||
const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
|
||||
// Load messages + status whenever the keyer is switched on.
|
||||
useEffect(() => {
|
||||
if (!dvkEnabled) return;
|
||||
reloadDvk();
|
||||
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
|
||||
}, [dvkEnabled, reloadDvk]);
|
||||
const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
|
||||
useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
|
||||
// Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
|
||||
// it from the keyboard without clashing with the F1-F12 keyer macros).
|
||||
type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||
@@ -586,6 +612,9 @@ export default function App() {
|
||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||
// and we don't yank the user out of the Cluster / QSL-manager tabs.
|
||||
useEffect(() => {
|
||||
// Opt-out: General settings can disable this auto-jump (read live so the
|
||||
// toggle takes effect without a reload).
|
||||
if (localStorage.getItem('opslog.autofocusWB') === '0') return;
|
||||
const c = callsign.trim().toUpperCase();
|
||||
if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return;
|
||||
if (lastWbFocusRef.current === c) return;
|
||||
@@ -1017,6 +1046,9 @@ export default function App() {
|
||||
// successful log AND by ESC. Locked values (band/mode/freq/start/end)
|
||||
// are preserved so backdated batches stay productive.
|
||||
function resetEntry() {
|
||||
// Discard any in-progress QSO recording (no-op if it was already saved on
|
||||
// log, or if the recorder is off).
|
||||
QSOAudioCancel();
|
||||
setCallsign(''); setComment(''); setNote('');
|
||||
if (!locks.start) setQsoStartedAt(null);
|
||||
if (!locks.end) setQsoEndedAt(null);
|
||||
@@ -1095,6 +1127,11 @@ export default function App() {
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function bulkUpdateFromClublog(ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
|
||||
// on demand (regardless of their current upload status). Runs in the
|
||||
// background; qslmgr:done refreshes the grid when finished.
|
||||
@@ -1199,6 +1236,10 @@ export default function App() {
|
||||
// reload worked-before + the band matrix, making them flicker. Compared
|
||||
// via the ref so it's correct even from the stale UDP closure.
|
||||
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
|
||||
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
|
||||
// keeps the pre-roll from before this); clearing it discards the take.
|
||||
// Both are no-ops when the recorder is off.
|
||||
if (v.trim() === '') QSOAudioCancel(); else QSOAudioBegin();
|
||||
const wasEmpty = callsign.trim() === '';
|
||||
const isEmpty = v.trim() === '';
|
||||
if (wasEmpty && !isEmpty && !locks.start) {
|
||||
@@ -1296,6 +1337,7 @@ export default function App() {
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
@@ -1304,7 +1346,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled]);
|
||||
], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1318,6 +1360,7 @@ export default function App() {
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
}
|
||||
@@ -1348,6 +1391,12 @@ export default function App() {
|
||||
// callsign depends on the "ESC clears callsign" option; with the keyer
|
||||
// off it always resets the entry (the classic behaviour).
|
||||
if (e.key === 'Escape') {
|
||||
// If a voice message is transmitting, ESC just stops it (keeps entry).
|
||||
if (dvkActiveRef.current && dvkPlayingRef.current) {
|
||||
DVKStop();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const keyerLive = wkActiveRef.current;
|
||||
if (keyerLive) WinkeyerStop().catch(() => {});
|
||||
if (!keyerLive || wkEscClearsRef.current) {
|
||||
@@ -1366,13 +1415,19 @@ export default function App() {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const plain = !mod && !e.altKey;
|
||||
if (wkActiveRef.current) {
|
||||
// Keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
|
||||
// CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
|
||||
// detail tab (so the two don't clash). Labels read "Ctrl+F1…".
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; }
|
||||
return;
|
||||
}
|
||||
// Keyer off: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
if (dvkActiveRef.current) {
|
||||
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
return;
|
||||
}
|
||||
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
return;
|
||||
}
|
||||
@@ -1970,15 +2025,26 @@ export default function App() {
|
||||
mode={mode}
|
||||
tab={detailTab}
|
||||
onTab={setDetailTab}
|
||||
keyerActive={wkEnabled && wkStatus.connected}
|
||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Reserved free space to the right. When the WinKeyer CW keyer is
|
||||
enabled it takes this slot (Log4OM-style); otherwise it shows the
|
||||
QRZ profile photo. */}
|
||||
{!compact && (wkEnabled || lookupResult?.image_url) && (
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
{dvkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
onPlay={dvkPlay}
|
||||
onStop={() => DVKStop()}
|
||||
onClose={() => setDvkEnabled(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="flex-1 min-w-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
@@ -2007,7 +2073,7 @@ export default function App() {
|
||||
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
||||
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
||||
{lookupResult?.image_url && (
|
||||
<div className={cn('min-w-0 flex items-center', wkEnabled ? 'shrink-0' : 'flex-1')}>
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||
@@ -2145,6 +2211,7 @@ export default function App() {
|
||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty}
|
||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
/>
|
||||
@@ -2477,7 +2544,7 @@ export default function App() {
|
||||
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onSendTo={bulkSendTo} />
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -2718,9 +2785,9 @@ export default function App() {
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fix country & zones from cty.dat
|
||||
Fix country & zones (cty.dat + ClubLog)
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Recompute Country, DXCC, CQ & ITU zones from cty.dat, overriding the file. Corrects wrong countries that contest software exports (e.g. RG2Y as Asiatic instead of European Russia). Everything else in the ADIF is kept as-is.
|
||||
Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user