mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 15:33:32 -07:00
Release v2.9.0 - iNTERCEPT rebrand and UI overhaul
- Rebrand from INTERCEPT to iNTERCEPT - New logo design with 'i' and signal wave brackets - Add animated landing page with "See the Invisible" tagline - Fix tuning dial audio issues with debouncing and restart prevention - Fix Listening Post scanner with proper signal hit logging - Update setup script for apt-based Python package installation - Add Instagram promo video template - Add full-size logo assets for external use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+1164
-4
File diff suppressed because it is too large
Load Diff
@@ -692,4 +692,36 @@ body {
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Embedded Mode Styles */
|
||||
body.embedded {
|
||||
background: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
body.embedded .header {
|
||||
background: rgba(10, 12, 16, 0.95);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
body.embedded .header .logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.embedded .header .logo span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body.embedded .dashboard {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.embedded .panel {
|
||||
background: rgba(15, 18, 24, 0.95);
|
||||
}
|
||||
|
||||
body.embedded .controls-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform (Dark Background Version) -->
|
||||
|
||||
<!-- Dark background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Subtle grid pattern -->
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#1a1a2e" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)"/>
|
||||
|
||||
<!-- Outer glow effect -->
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#glow)">
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform -->
|
||||
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Intercept - Radio Knob Component
|
||||
* Interactive rotary knob control with drag-to-rotate
|
||||
*/
|
||||
|
||||
class RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.value = parseFloat(element.dataset.value) || 0;
|
||||
this.min = parseFloat(element.dataset.min) || 0;
|
||||
this.max = parseFloat(element.dataset.max) || 100;
|
||||
this.step = parseFloat(element.dataset.step) || 1;
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.isDragging = false;
|
||||
this.startY = 0;
|
||||
this.startRotation = 0;
|
||||
this.sensitivity = options.sensitivity || 1.5;
|
||||
this.onChange = options.onChange || null;
|
||||
|
||||
this.bindEvents();
|
||||
this.updateVisual();
|
||||
}
|
||||
|
||||
valueToRotation(value) {
|
||||
const range = this.max - this.min;
|
||||
const normalized = (value - this.min) / range;
|
||||
return normalized * 270 - 135; // -135 to +135 degrees
|
||||
}
|
||||
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
|
||||
// Snap to step
|
||||
value = Math.round(value / this.step) * this.step;
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Mouse events
|
||||
this.element.addEventListener('mousedown', (e) => this.startDrag(e));
|
||||
document.addEventListener('mousemove', (e) => this.drag(e));
|
||||
document.addEventListener('mouseup', () => this.endDrag());
|
||||
|
||||
// Touch support
|
||||
this.element.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0]);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (this.isDragging) {
|
||||
e.preventDefault();
|
||||
this.drag(e.touches[0]);
|
||||
}
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', () => this.endDrag());
|
||||
|
||||
// Scroll wheel support
|
||||
this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||
|
||||
// Double-click to reset
|
||||
this.element.addEventListener('dblclick', () => this.reset());
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.startY = e.clientY;
|
||||
this.startRotation = this.rotation;
|
||||
this.element.style.cursor = 'grabbing';
|
||||
this.element.classList.add('active');
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaY = this.startY - e.clientY;
|
||||
let newRotation = this.startRotation + deltaY * this.sensitivity;
|
||||
|
||||
// Clamp rotation
|
||||
newRotation = Math.max(-135, Math.min(135, newRotation));
|
||||
|
||||
this.rotation = newRotation;
|
||||
this.value = this.rotationToValue(this.rotation);
|
||||
this.updateVisual();
|
||||
this.dispatchChange();
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.element.style.cursor = 'grab';
|
||||
this.element.classList.remove('active');
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -this.step : this.step;
|
||||
const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
|
||||
this.setValue(this.value + delta * multiplier);
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, silent = false) {
|
||||
this.value = Math.max(this.min, Math.min(this.max, value));
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.updateVisual();
|
||||
if (!silent) {
|
||||
this.dispatchChange();
|
||||
}
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
reset() {
|
||||
const defaultValue = parseFloat(this.element.dataset.default) ||
|
||||
(this.min + this.max) / 2;
|
||||
this.setValue(defaultValue);
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display
|
||||
const valueDisplayId = this.element.id.replace('Knob', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = Math.round(this.value);
|
||||
}
|
||||
|
||||
// Update data attribute
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
|
||||
dispatchChange() {
|
||||
// Custom callback
|
||||
if (this.onChange) {
|
||||
this.onChange(this.value, this);
|
||||
}
|
||||
|
||||
// Custom event
|
||||
this.element.dispatchEvent(new CustomEvent('knobchange', {
|
||||
detail: { value: this.value, knob: this },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning Dial - Larger rotary control for frequency tuning
|
||||
*/
|
||||
class TuningDial extends RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
super(element, {
|
||||
sensitivity: options.sensitivity || 0.8,
|
||||
...options
|
||||
});
|
||||
|
||||
this.fineStep = options.fineStep || 0.025;
|
||||
this.coarseStep = options.coarseStep || 0.2;
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? this.fineStep : this.coarseStep;
|
||||
const delta = e.deltaY > 0 ? -step : step;
|
||||
this.setValue(this.value + delta);
|
||||
}
|
||||
|
||||
// Override to not round to step for smooth tuning
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display with decimals
|
||||
const valueDisplayId = this.element.id.replace('Dial', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = this.value.toFixed(3);
|
||||
}
|
||||
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all radio knobs on the page
|
||||
*/
|
||||
function initRadioKnobs() {
|
||||
// Initialize standard knobs
|
||||
document.querySelectorAll('.radio-knob').forEach(element => {
|
||||
if (!element._knob) {
|
||||
element._knob = new RadioKnob(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tuning dials
|
||||
document.querySelectorAll('.tuning-dial').forEach(element => {
|
||||
if (!element._dial) {
|
||||
element._dial = new TuningDial(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initRadioKnobs);
|
||||
|
||||
// Export for use in modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { RadioKnob, TuningDial, initRadioKnobs };
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Intercept - Core Application Logic
|
||||
* Global state, mode switching, and shared functionality
|
||||
*/
|
||||
|
||||
// ============== GLOBAL STATE ==============
|
||||
|
||||
// Mode state flags
|
||||
let eventSource = null;
|
||||
let isRunning = false;
|
||||
let isSensorRunning = false;
|
||||
let isAdsbRunning = false;
|
||||
let isWifiRunning = false;
|
||||
let isBtRunning = false;
|
||||
let currentMode = 'pager';
|
||||
|
||||
// Message counters
|
||||
let msgCount = 0;
|
||||
let pocsagCount = 0;
|
||||
let flexCount = 0;
|
||||
let sensorCount = 0;
|
||||
let filteredCount = 0;
|
||||
|
||||
// Device list (populated from server via Jinja2)
|
||||
let deviceList = [];
|
||||
|
||||
// Auto-scroll setting
|
||||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||||
|
||||
// Mute setting
|
||||
let muted = localStorage.getItem('audioMuted') === 'true';
|
||||
|
||||
// Observer location (load from localStorage or default to London)
|
||||
let observerLocation = (function() {
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
// Message storage for export
|
||||
let allMessages = [];
|
||||
|
||||
// Track unique sensor devices
|
||||
let uniqueDevices = new Set();
|
||||
|
||||
// SDR device usage tracking
|
||||
let sdrDeviceUsage = {};
|
||||
|
||||
// ============== DISCLAIMER HANDLING ==============
|
||||
|
||||
function checkDisclaimer() {
|
||||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||||
if (accepted === 'true') {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function acceptDisclaimer() {
|
||||
localStorage.setItem('disclaimerAccepted', 'true');
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
|
||||
function declineDisclaimer() {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||||
}
|
||||
|
||||
// ============== HEADER CLOCK ==============
|
||||
|
||||
function updateHeaderClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().substring(11, 19);
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
}
|
||||
|
||||
// ============== HEADER STATS SYNC ==============
|
||||
|
||||
function syncHeaderStats() {
|
||||
// Pager stats
|
||||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||||
|
||||
// Sensor stats
|
||||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||||
|
||||
// WiFi stats
|
||||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Aircraft stats
|
||||
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
|
||||
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
|
||||
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
|
||||
function switchMode(mode) {
|
||||
// Stop any running scans when switching modes
|
||||
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
|
||||
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
|
||||
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
|
||||
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
|
||||
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
|
||||
|
||||
currentMode = mode;
|
||||
|
||||
// Remove active from all nav buttons, then add to the correct one
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
'pager': 'PAGER',
|
||||
'sensor': '433MHZ',
|
||||
'aircraft': 'AIRCRAFT',
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
'pager': 'Pager Decoder',
|
||||
'sensor': '433MHz Sensor Monitor',
|
||||
'aircraft': 'ADS-B Aircraft Tracker',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
|
||||
if (typeof initRadar === 'function') initRadar();
|
||||
if (typeof initWatchList === 'function') initWatchList();
|
||||
} else if (mode === 'bluetooth') {
|
||||
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
|
||||
if (typeof initBtRadar === 'function') initBtRadar();
|
||||
} else if (mode === 'aircraft') {
|
||||
if (typeof checkAdsbTools === 'function') checkAdsbTools();
|
||||
if (typeof initAircraftRadar === 'function') initAircraftRadar();
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SECTION COLLAPSE ==============
|
||||
|
||||
function toggleSection(el) {
|
||||
el.closest('.section').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ============== THEME MANAGEMENT ==============
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update button text
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AUTO-SCROLL ==============
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
localStorage.setItem('autoScroll', autoScroll);
|
||||
updateAutoScrollButton();
|
||||
}
|
||||
|
||||
function updateAutoScrollButton() {
|
||||
const btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||||
btn.classList.toggle('active', autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SDR DEVICE MANAGEMENT ==============
|
||||
|
||||
function getSelectedDevice() {
|
||||
return document.getElementById('deviceSelect').value;
|
||||
}
|
||||
|
||||
function getSelectedSDRType() {
|
||||
return document.getElementById('sdrTypeSelect').value;
|
||||
}
|
||||
|
||||
function reserveDevice(deviceIndex, modeId) {
|
||||
sdrDeviceUsage[modeId] = deviceIndex;
|
||||
}
|
||||
|
||||
function releaseDevice(modeId) {
|
||||
delete sdrDeviceUsage[modeId];
|
||||
}
|
||||
|
||||
function checkDeviceAvailability(requestingMode) {
|
||||
const selectedDevice = parseInt(getSelectedDevice());
|
||||
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
|
||||
if (mode !== requestingMode && device === selectedDevice) {
|
||||
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============== BIAS-T SETTINGS ==============
|
||||
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
return document.getElementById('biasT')?.checked || false;
|
||||
}
|
||||
|
||||
function loadBiasTSetting() {
|
||||
const saved = localStorage.getItem('biasTEnabled');
|
||||
if (saved === 'true') {
|
||||
const checkbox = document.getElementById('biasT');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== REMOTE SDR ==============
|
||||
|
||||
function toggleRemoteSDR() {
|
||||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||||
const configDiv = document.getElementById('remoteSDRConfig');
|
||||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||||
|
||||
if (useRemote) {
|
||||
configDiv.style.display = 'block';
|
||||
localControls.forEach(el => el.disabled = true);
|
||||
} else {
|
||||
configDiv.style.display = 'none';
|
||||
localControls.forEach(el => el.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteSDRConfig() {
|
||||
const useRemote = document.getElementById('useRemoteSDR')?.checked;
|
||||
if (!useRemote) return null;
|
||||
|
||||
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
|
||||
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
|
||||
|
||||
if (!host || isNaN(port)) {
|
||||
alert('Please enter valid rtl_tcp host and port');
|
||||
return false;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
// ============== OUTPUT DISPLAY ==============
|
||||
|
||||
function showInfo(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const infoEl = document.createElement('div');
|
||||
infoEl.className = 'info-msg';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.textContent = text;
|
||||
output.insertBefore(infoEl, output.firstChild);
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-msg';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.textContent = '⚠ ' + text;
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
// ============== OBSERVER LOCATION ==============
|
||||
|
||||
function saveObserverLocation() {
|
||||
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Sync both input sets
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function useGeolocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Update all input fields
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
|
||||
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||
},
|
||||
(error) => {
|
||||
showError('Geolocation failed: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError('Geolocation not supported by browser');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EXPORT FUNCTIONS ==============
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
|
||||
// Load theme
|
||||
loadTheme();
|
||||
|
||||
// Start clock
|
||||
updateHeaderClock();
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
|
||||
// Start stats sync
|
||||
setInterval(syncHeaderStats, 500);
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
// Initialize observer location inputs
|
||||
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Update UI state
|
||||
updateAutoScrollButton();
|
||||
|
||||
// Make sections collapsible
|
||||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||||
h3.addEventListener('click', function() {
|
||||
this.parentElement.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Collapse all sections by default (except SDR Device which is first)
|
||||
document.querySelectorAll('.section').forEach((section, index) => {
|
||||
if (index > 0) {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Intercept - Audio System
|
||||
* Web Audio API alerts, notifications, and sound effects
|
||||
*/
|
||||
|
||||
// ============== AUDIO STATE ==============
|
||||
|
||||
let audioContext = null;
|
||||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||||
let notificationsEnabled = false;
|
||||
|
||||
// ============== AUDIO CONTEXT ==============
|
||||
|
||||
/**
|
||||
* Initialize the Web Audio API context
|
||||
* Must be called after user interaction due to browser autoplay policies
|
||||
*/
|
||||
function initAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the audio context
|
||||
* @returns {AudioContext}
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// ============== ALERT SOUNDS ==============
|
||||
|
||||
/**
|
||||
* Play a basic alert beep
|
||||
* Used for message received notifications
|
||||
*/
|
||||
function playAlert() {
|
||||
if (audioMuted || !audioContext) return;
|
||||
|
||||
try {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play alert sound by type
|
||||
* @param {string} type - 'emergency', 'military', 'warning', 'info'
|
||||
*/
|
||||
function playAlertSound(type) {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
switch (type) {
|
||||
case 'emergency':
|
||||
// Urgent two-tone alert for emergencies
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
break;
|
||||
|
||||
case 'military':
|
||||
// Single tone for military aircraft detection
|
||||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'warning':
|
||||
// Warning tone (descending)
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
default:
|
||||
// Simple info tone
|
||||
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.15);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play scanner signal detected sound
|
||||
* A distinctive ascending tone for radio scanner
|
||||
*/
|
||||
function playSignalDetectedSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// Ascending tone
|
||||
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Signal detected sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a click sound for UI feedback
|
||||
*/
|
||||
function playClickSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = 1000;
|
||||
oscillator.type = 'square';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {
|
||||
console.warn('Click sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MUTE CONTROL ==============
|
||||
|
||||
/**
|
||||
* Toggle mute state
|
||||
*/
|
||||
function toggleMute() {
|
||||
audioMuted = !audioMuted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
* @param {boolean} muted - Whether audio should be muted
|
||||
*/
|
||||
function setMuted(muted) {
|
||||
audioMuted = muted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mute state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMuted() {
|
||||
return audioMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mute button UI
|
||||
*/
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== DESKTOP NOTIFICATIONS ==============
|
||||
|
||||
/**
|
||||
* Request notification permission from user
|
||||
*/
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(permission => {
|
||||
notificationsEnabled = permission === 'granted';
|
||||
if (notificationsEnabled && typeof showInfo === 'function') {
|
||||
showInfo('🔔 Desktop notifications enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a desktop notification
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, body) {
|
||||
if (notificationsEnabled && document.hidden) {
|
||||
new Notification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'intercept-' + Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
/**
|
||||
* Initialize audio system
|
||||
* Should be called on first user interaction
|
||||
*/
|
||||
function initAudioSystem() {
|
||||
// Initialize audio context
|
||||
initAudio();
|
||||
|
||||
// Update mute button state
|
||||
updateMuteButton();
|
||||
|
||||
// Check notification permission
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
notificationsEnabled = true;
|
||||
} else if (Notification.permission === 'default') {
|
||||
// Will request on first interaction
|
||||
document.addEventListener('click', function requestOnce() {
|
||||
requestNotificationPermission();
|
||||
document.removeEventListener('click', requestOnce);
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first user interaction (required for Web Audio API)
|
||||
document.addEventListener('click', function initOnInteraction() {
|
||||
initAudio();
|
||||
document.removeEventListener('click', initOnInteraction);
|
||||
}, { once: true });
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Intercept - Core Utility Functions
|
||||
* Pure utility functions with no DOM dependencies
|
||||
*/
|
||||
|
||||
// ============== HTML ESCAPING ==============
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape text for use in HTML attributes (especially onclick handlers)
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
var s = String(text);
|
||||
s = s.replace(/&/g, '&');
|
||||
s = s.replace(/'/g, ''');
|
||||
s = s.replace(/"/g, '"');
|
||||
s = s.replace(/</g, '<');
|
||||
s = s.replace(/>/g, '>');
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============== VALIDATION ==============
|
||||
|
||||
/**
|
||||
* Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||||
* @param {string} mac - MAC address to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidMac(mac) {
|
||||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WiFi channel (1-200 covers all bands)
|
||||
* @param {string|number} ch - Channel number
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidChannel(ch) {
|
||||
const num = parseInt(ch, 10);
|
||||
return !isNaN(num) && num >= 1 && num <= 200;
|
||||
}
|
||||
|
||||
// ============== TIME FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Get relative time string from timestamp
|
||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||
* @returns {string} Relative time like "5s ago", "2m ago"
|
||||
*/
|
||||
function getRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const parts = timestamp.split(':');
|
||||
const msgTime = new Date();
|
||||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||||
|
||||
const diff = Math.floor((now - msgTime) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UTC time string
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} UTC time in HH:MM:SS format
|
||||
*/
|
||||
function formatUtcTime(date) {
|
||||
return date.toISOString().substring(11, 19);
|
||||
}
|
||||
|
||||
// ============== DISTANCE CALCULATIONS ==============
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in nautical miles
|
||||
* Uses Haversine formula
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in nautical miles
|
||||
*/
|
||||
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
|
||||
const R = 3440.065; // Earth radius in nautical miles
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
function calculateDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// ============== FILE OPERATIONS ==============
|
||||
|
||||
/**
|
||||
* Download content as a file
|
||||
* @param {string} content - File content
|
||||
* @param {string} filename - Name for the downloaded file
|
||||
* @param {string} type - MIME type
|
||||
*/
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ============== FREQUENCY FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format frequency value with proper units
|
||||
* @param {number} freqMhz - Frequency in MHz
|
||||
* @param {number} decimals - Number of decimal places (default 3)
|
||||
* @returns {string} Formatted frequency string
|
||||
*/
|
||||
function formatFrequency(freqMhz, decimals = 3) {
|
||||
return freqMhz.toFixed(decimals) + ' MHz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frequency string to MHz
|
||||
* @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
|
||||
* @returns {number} Frequency in MHz
|
||||
*/
|
||||
function parseFrequency(freqStr) {
|
||||
return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
|
||||
}
|
||||
|
||||
// ============== LOCAL STORAGE HELPERS ==============
|
||||
|
||||
/**
|
||||
* Get item from localStorage with JSON parsing
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} defaultValue - Default value if key doesn't exist
|
||||
* @returns {*} Parsed value or default
|
||||
*/
|
||||
function getStorageItem(key, defaultValue = null) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage with JSON stringification
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== ARRAY/OBJECT UTILITIES ==============
|
||||
|
||||
/**
|
||||
* Debounce function execution
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function execution
|
||||
* @param {Function} func - Function to throttle
|
||||
* @param {number} limit - Time limit in milliseconds
|
||||
* @returns {Function} Throttled function
|
||||
*/
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============== NUMBER FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format large numbers with K/M suffixes
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
* @param {number} num - Number to clamp
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @returns {number} Clamped value
|
||||
*/
|
||||
function clamp(num, min, max) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from one range to another
|
||||
* @param {number} value - Value to map
|
||||
* @param {number} inMin - Input range minimum
|
||||
* @param {number} inMax - Input range maximum
|
||||
* @param {number} outMin - Output range minimum
|
||||
* @param {number} outMax - Output range maximum
|
||||
* @returns {number} Mapped value
|
||||
*/
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user