mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-04 05:18:13 +02:00
528 lines
21 KiB
TypeScript
528 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { API_BASE } from '@/lib/api';
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
RadioReceiver,
|
|
Activity,
|
|
Play,
|
|
Square,
|
|
FastForward,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} from 'lucide-react';
|
|
import type { DashboardData, SelectedEntity, RadioFeed, SigintSignal } from '@/types/dashboard';
|
|
|
|
export default function RadioInterceptPanel({
|
|
data,
|
|
isEavesdropping,
|
|
setIsEavesdropping,
|
|
eavesdropLocation,
|
|
cameraCenter,
|
|
selectedEntity: _selectedEntity,
|
|
}: {
|
|
data: DashboardData;
|
|
isEavesdropping?: boolean;
|
|
setIsEavesdropping?: (val: boolean) => void;
|
|
eavesdropLocation?: { lat: number; lng: number } | null;
|
|
cameraCenter?: { lat: number; lng: number } | null;
|
|
selectedEntity?: SelectedEntity | null;
|
|
}) {
|
|
const [isMinimized, setIsMinimized] = useState(true);
|
|
const [feeds, setFeeds] = useState<RadioFeed[]>([]);
|
|
const [activeFeed, setActiveFeed] = useState<RadioFeed | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const [volume, setVolume] = useState(0.8);
|
|
const volumeRef = useRef(volume);
|
|
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Fetch the top feeds on mount
|
|
useEffect(() => {
|
|
const fetchFeeds = async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/radio/top`);
|
|
if (res.ok) {
|
|
const json = await res.json();
|
|
setFeeds(json);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch radio feeds', e);
|
|
}
|
|
};
|
|
fetchFeeds();
|
|
// Refresh every 5 minutes
|
|
const interval = setInterval(fetchFeeds, 300000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const playFeed = useCallback((feed: RadioFeed) => {
|
|
if (isScanning && scanTimeoutRef.current) {
|
|
clearTimeout(scanTimeoutRef.current);
|
|
setIsScanning(false);
|
|
}
|
|
setActiveFeed(feed);
|
|
setIsPlaying(true);
|
|
}, [isScanning]);
|
|
|
|
// Handle Eavesdrop Map Clicks
|
|
useEffect(() => {
|
|
if (eavesdropLocation && isEavesdropping) {
|
|
const fetchNearest = async () => {
|
|
try {
|
|
// Show a temporary state
|
|
setFeeds((prev) => [
|
|
{
|
|
id: 'scanning-nearest',
|
|
name: 'TRIANGULATING SIGNAL...',
|
|
location: `LAT:${eavesdropLocation.lat.toFixed(2)} LNG:${eavesdropLocation.lng.toFixed(2)}`,
|
|
listeners: 0,
|
|
category: 'SIGINT',
|
|
},
|
|
...prev,
|
|
]);
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/radio/nearest?lat=${eavesdropLocation.lat}&lng=${eavesdropLocation.lng}`,
|
|
);
|
|
if (res.ok) {
|
|
const system = await res.json();
|
|
if (system && system.shortName) {
|
|
// Valid OpenMHZ system found! Fetch recent calls
|
|
const callRes = await fetch(
|
|
`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`,
|
|
);
|
|
if (callRes.ok) {
|
|
const calls = await callRes.json();
|
|
if (calls && calls.length > 0) {
|
|
// Found bursts!
|
|
const latest = calls[0];
|
|
const openMhzFeed = {
|
|
id: `openmhz-${system.shortName}-${latest.id}`,
|
|
name: `${system.name} (TG:${latest.talkgroupNum})`,
|
|
location: `${system.city}, ${system.state}`,
|
|
listeners: system.clientCount || 0,
|
|
category: 'TRUNKED INTERCEPT',
|
|
stream_url: latest.url,
|
|
};
|
|
|
|
// Remove the triangulating placeholder and add the new intercept
|
|
setFeeds((prev) => {
|
|
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
|
// Avoid duplicates if we clicked the same place twice
|
|
if (clean.find((f) => f.id === openMhzFeed.id)) return clean;
|
|
return [openMhzFeed, ...clean];
|
|
});
|
|
// Auto-play the intercept
|
|
playFeed(openMhzFeed);
|
|
} else {
|
|
// Provide failure feedback
|
|
setFeeds((prev) => {
|
|
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
|
return [
|
|
{
|
|
id: `failed-${Date.now()}`,
|
|
name: `NO RECENT COMMS (${system.shortName})`,
|
|
location: `${system.city}, ${system.state}`,
|
|
category: 'DEAD AIR',
|
|
listeners: 0,
|
|
},
|
|
...clean,
|
|
];
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// Provide failure feedback
|
|
setFeeds((prev) => {
|
|
const clean = prev.filter((f) => f.id !== 'scanning-nearest');
|
|
return [
|
|
{
|
|
id: `failed-${Date.now()}`,
|
|
name: 'NO LOCAL REPEATERS FOUND',
|
|
location: 'UNKNOWN',
|
|
category: 'ENCRYPTED / VOID',
|
|
listeners: 0,
|
|
},
|
|
...clean,
|
|
];
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Nearest system lookup failed', e);
|
|
}
|
|
};
|
|
fetchNearest();
|
|
}
|
|
}, [eavesdropLocation, isEavesdropping, playFeed]);
|
|
|
|
const stopFeed = useCallback(() => {
|
|
if (isScanning && scanTimeoutRef.current) {
|
|
clearTimeout(scanTimeoutRef.current);
|
|
setIsScanning(false);
|
|
}
|
|
setActiveFeed(null);
|
|
setIsPlaying(false);
|
|
}, [isScanning]);
|
|
|
|
// Handle Audio Element Play/Stop
|
|
useEffect(() => {
|
|
if (activeFeed && isPlaying) {
|
|
if (!audioRef.current) {
|
|
const audio = new Audio(activeFeed.stream_url || '');
|
|
audioRef.current = audio;
|
|
} else {
|
|
audioRef.current.src = activeFeed.stream_url || '';
|
|
}
|
|
audioRef.current.volume = volumeRef.current;
|
|
audioRef.current.play().catch((e) => console.log('Audio play blocked', e));
|
|
} else {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
}
|
|
}, [activeFeed, isPlaying]);
|
|
|
|
useEffect(() => {
|
|
volumeRef.current = volume;
|
|
if (audioRef.current) {
|
|
audioRef.current.volume = volume;
|
|
}
|
|
}, [volume]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current.src = '';
|
|
}
|
|
if (scanTimeoutRef.current) {
|
|
clearTimeout(scanTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const toggleScan = () => {
|
|
if (isScanning) {
|
|
setIsScanning(false);
|
|
if (scanTimeoutRef.current) clearTimeout(scanTimeoutRef.current);
|
|
stopFeed();
|
|
} else {
|
|
setIsScanning(true);
|
|
scanNextFeed();
|
|
}
|
|
};
|
|
|
|
const scanNextFeed = async () => {
|
|
if (!isScanning) return;
|
|
|
|
// Try localized scan first if we have a camera center or eavesdrop location
|
|
const scanLoc = eavesdropLocation || cameraCenter;
|
|
|
|
let localFeedFound = false;
|
|
|
|
if (scanLoc) {
|
|
try {
|
|
const res = await fetch(
|
|
`${API_BASE}/api/radio/nearest-list?lat=${scanLoc.lat}&lng=${scanLoc.lng}&limit=3`,
|
|
);
|
|
if (res.ok) {
|
|
const systems = await res.json();
|
|
|
|
// Try to find a system with an active unplayed burst
|
|
for (const system of systems) {
|
|
if (system && system.shortName) {
|
|
const callRes = await fetch(
|
|
`${API_BASE}/api/radio/openmhz/calls/${system.shortName}`,
|
|
);
|
|
if (callRes.ok) {
|
|
const calls = await callRes.json();
|
|
if (calls && calls.length > 0) {
|
|
// Normally we would track played calls. For now just pick random recent one.
|
|
const randomCall = calls[Math.floor(Math.random() * Math.min(calls.length, 3))];
|
|
const openMhzFeed = {
|
|
id: `openmhz-${system.shortName}-${randomCall.id}`,
|
|
name: `${system.name} (TG:${randomCall.talkgroupNum})`,
|
|
location: `${system.city}, ${system.state}`,
|
|
listeners: system.clientCount || 0,
|
|
category: 'TRUNKED INTERCEPT',
|
|
stream_url: randomCall.url,
|
|
};
|
|
|
|
// Replace feeds list visually with this active sector
|
|
setFeeds((prev) => {
|
|
if (prev.find((f) => f.id === openMhzFeed.id)) return prev;
|
|
return [openMhzFeed, ...prev].slice(0, 10);
|
|
});
|
|
setActiveFeed(openMhzFeed);
|
|
setIsPlaying(true);
|
|
localFeedFound = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Auto scan local query failed', e);
|
|
}
|
|
}
|
|
|
|
if (!localFeedFound && feeds.length > 0) {
|
|
// Fallback: Pick a random hot feed or cycle them
|
|
const randomIdx = Math.floor(Math.random() * Math.min(feeds.length, 10)); // Pick from top 10
|
|
setActiveFeed(feeds[randomIdx]);
|
|
setIsPlaying(true);
|
|
}
|
|
|
|
// Scan for 15 seconds then switch
|
|
scanTimeoutRef.current = setTimeout(() => {
|
|
if (isScanning) scanNextFeed();
|
|
}, 15000);
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 50 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 1, delay: 0.2 }}
|
|
className="w-full flex flex-col bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 pointer-events-auto relative overflow-hidden max-h-full"
|
|
>
|
|
<div
|
|
className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]/50 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
|
onClick={() => setIsMinimized(!isMinimized)}
|
|
>
|
|
<div className="flex items-center gap-2 text-[var(--text-muted)]">
|
|
<RadioReceiver size={14} className={isPlaying ? 'animate-pulse' : ''} />
|
|
<span className="text-[10px] font-mono tracking-widest">SIGINT INTERCEPT</span>
|
|
{isPlaying && <Activity size={12} className="text-red-500 animate-pulse ml-2" />}
|
|
</div>
|
|
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
|
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{!isMinimized && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="flex flex-col overflow-hidden"
|
|
>
|
|
{/* Audio Player Controls */}
|
|
<div className="p-4 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/60">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex flex-col">
|
|
<span className="text-xs text-cyan-300 font-mono tracking-wide">
|
|
{activeFeed ? activeFeed.name : 'NO SIGNAL'}
|
|
</span>
|
|
<span className="text-[9px] text-[var(--text-muted)] font-mono">
|
|
{activeFeed
|
|
? `LOCATION: ${activeFeed.location.toUpperCase()}`
|
|
: 'AWAITING TUNING...'}
|
|
</span>
|
|
</div>
|
|
{activeFeed && (
|
|
<div className="flex items-center gap-1 bg-red-950/40 border border-red-900/50 px-2 py-0.5 text-[9px] text-red-400 font-mono">
|
|
<Activity size={10} className="animate-pulse" />
|
|
LIVE
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={activeFeed ? stopFeed : () => feeds.length > 0 && playFeed(feeds[0])}
|
|
className={`p-2 rounded-full border ${activeFeed ? 'border-red-500/50 text-red-500 hover:bg-red-950/50' : 'border-cyan-700 text-cyan-500 hover:bg-cyan-900/50'} transition-colors`}
|
|
>
|
|
{activeFeed ? <Square size={14} /> : <Play size={14} className="ml-0.5" />}
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleScan}
|
|
className={`px-3 py-1.5 text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isScanning ? 'bg-cyan-900/60 border-cyan-400 text-cyan-300' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
|
>
|
|
<FastForward size={12} />
|
|
{isScanning ? 'SCANNING...' : 'AUTO SCAN'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setIsEavesdropping && setIsEavesdropping(!isEavesdropping)}
|
|
className={`px-3 py-1.5 text-[10px] font-mono border tracking-wider flex items-center gap-2 ${isEavesdropping ? 'bg-red-900/60 border-red-500 text-red-300 animate-pulse' : 'border-cyan-800 text-cyan-600 hover:border-cyan-600'} transition-colors`}
|
|
title="Click on the globe to intercept local signals"
|
|
>
|
|
EAVESDROP
|
|
</button>
|
|
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={volume}
|
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
|
className="w-20 accent-cyan-500"
|
|
title="Volume"
|
|
/>
|
|
</div>
|
|
|
|
{/* Fake Waveform Visualizer */}
|
|
<div className="mt-4 flex items-end gap-[2px] h-8 opacity-70">
|
|
{Array.from({ length: 48 }).map((_, i) => (
|
|
<motion.div
|
|
key={i}
|
|
className={`w-1 rounded-t-sm ${isPlaying ? 'bg-cyan-500' : 'bg-cyan-900/50'}`}
|
|
animate={{
|
|
height: isPlaying ? ['10%', `${Math.random() * 80 + 20}%`, '10%'] : '10%',
|
|
}}
|
|
transition={{
|
|
repeat: Infinity,
|
|
duration: Math.random() * 0.5 + 0.3,
|
|
ease: 'easeInOut',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Feed List */}
|
|
<div className="flex-col overflow-y-auto styled-scrollbar max-h-64 p-2">
|
|
{feeds.length === 0 ? (
|
|
<div className="text-[10px] text-cyan-700 font-mono text-center p-4">
|
|
SEARCHING FREQUENCIES...
|
|
</div>
|
|
) : (
|
|
feeds.map((feed: RadioFeed) => (
|
|
<div
|
|
key={feed.id}
|
|
onClick={() => playFeed(feed)}
|
|
className={`p-2 mb-1 cursor-pointer border-l-2 ${activeFeed?.id === feed.id ? 'bg-cyan-900/30 border-cyan-400' : 'border-transparent hover:bg-white/5'} flex justify-between items-center transition-colors`}
|
|
>
|
|
<div className="flex flex-col overflow-hidden pr-2">
|
|
<span
|
|
className={`text-[11px] font-mono truncate ${activeFeed?.id === feed.id ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}
|
|
>
|
|
{feed.name}
|
|
</span>
|
|
<span className="text-[9px] text-[var(--text-muted)] font-mono truncate">
|
|
{feed.location} | {feed.category}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-end flex-shrink-0">
|
|
<span className="text-[10px] text-cyan-600 font-mono flex items-center gap-1">
|
|
<Activity size={10} />
|
|
{feed.listeners.toLocaleString()}
|
|
</span>
|
|
<span className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
|
|
LSTN
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* SIGINT Grid Section */}
|
|
{data?.sigint && data.sigint.length > 0 && (
|
|
<div className="border-t border-[var(--border-primary)]/40">
|
|
<div className="px-3 py-2 flex items-center justify-between">
|
|
<span className="text-[9px] font-mono tracking-widest text-emerald-400 font-bold">
|
|
SIGINT GRID
|
|
</span>
|
|
<div className="flex items-center gap-2 text-[11px] font-mono">
|
|
<span className="text-green-400">
|
|
APRS:{data.sigint.filter((s: SigintSignal) => s.source === 'aprs').length}
|
|
</span>
|
|
<span className="text-blue-400">
|
|
MESH:
|
|
{data.sigint.filter((s: SigintSignal) => s.source === 'meshtastic').length}
|
|
</span>
|
|
<span className="text-amber-400">
|
|
JS8:{data.sigint.filter((s: SigintSignal) => s.source === 'js8call').length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex-col overflow-y-auto styled-scrollbar max-h-60 px-2 pb-2">
|
|
{data.sigint.slice(0, 25).map((sig: SigintSignal, idx: number) => {
|
|
const srcColor =
|
|
sig.source === 'aprs'
|
|
? '#22c55e'
|
|
: sig.source === 'meshtastic'
|
|
? '#3b82f6'
|
|
: '#f59e0b';
|
|
// Build a context line from the richest available field
|
|
const context =
|
|
sig.status || sig.comment || sig.raw_message?.slice(0, 60) || '';
|
|
const stationType =
|
|
sig.station_type && sig.station_type !== 'Station' ? sig.station_type : '';
|
|
const freq = sig.frequency || '';
|
|
return (
|
|
<div
|
|
key={`${sig.source}-${sig.callsign}-${idx}`}
|
|
className={`p-1.5 mb-0.5 hover:bg-white/5 transition-colors border-l-2 cursor-pointer ${sig.emergency ? 'bg-red-950/20' : ''}`}
|
|
style={{ borderColor: sig.emergency ? '#ef4444' : srcColor }}
|
|
onClick={() => {
|
|
if (sig.lat && sig.lng) {
|
|
// Dispatch a custom event to fly to this signal
|
|
window.dispatchEvent(
|
|
new CustomEvent('flyto', {
|
|
detail: { lat: sig.lat, lng: sig.lng, zoom: 10 },
|
|
}),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between gap-1">
|
|
<span className="text-[10px] font-mono text-[var(--text-secondary)] truncate font-medium">
|
|
{sig.callsign}
|
|
</span>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{sig.emergency && (
|
|
<span className="text-[10px] font-mono text-red-400 bg-red-500/20 px-1 tracking-wider">
|
|
SOS
|
|
</span>
|
|
)}
|
|
<span
|
|
className="text-[10px] font-mono tracking-wider px-1"
|
|
style={{ color: srcColor, backgroundColor: `${srcColor}15` }}
|
|
>
|
|
{(sig.source || '').toUpperCase()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{(stationType || freq) && (
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
{stationType && (
|
|
<span className="text-[11px] text-cyan-500/70 font-mono truncate">
|
|
{stationType}
|
|
</span>
|
|
)}
|
|
{freq && (
|
|
<span className="text-[11px] text-amber-500/70 font-mono">{freq}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{context && (
|
|
<p className="text-[11px] text-gray-400 font-mono truncate mt-0.5 leading-tight">
|
|
{context.slice(0, 70)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
}
|