From 70dabf3323ef4b80c7fbc2c94476da64208864ed Mon Sep 17 00:00:00 2001 From: Smittix Date: Sat, 10 Jan 2026 01:00:17 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 32 +- app.py | 8 + config.py | 2 +- docs/TROUBLESHOOTING.md | 181 ++- favicon.svg | 24 +- promo/instagram-promo.html | 898 ++++++++++++ requirements.txt | 1 + routes/audio_websocket.py | 256 ++++ routes/listening_post.py | 193 ++- setup.sh | 42 +- static/css/index.css | 1168 ++++++++++++++- static/css/satellite_dashboard.css | 32 + static/img/intercept-logo-dark.svg | 51 + static/img/intercept-logo.svg | 27 + static/js/components/radio-knob.js | 226 +++ static/js/core/app.js | 547 +++++++ static/js/core/audio.js | 281 ++++ static/js/core/utils.js | 273 ++++ static/js/modes/listening-post.js | 2194 ++++++++++++++++++++++++++++ templates/index.html | 2179 +++++---------------------- templates/satellite_dashboard.html | 36 +- 21 files changed, 6735 insertions(+), 1916 deletions(-) create mode 100644 promo/instagram-promo.html create mode 100644 routes/audio_websocket.py create mode 100644 static/img/intercept-logo-dark.svg create mode 100644 static/img/intercept-logo.svg create mode 100644 static/js/components/radio-knob.js create mode 100644 static/js/core/app.js create mode 100644 static/js/core/audio.js create mode 100644 static/js/core/utils.js create mode 100644 static/js/modes/listening-post.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dbd83..6af9751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,36 @@ # Changelog -All notable changes to INTERCEPT will be documented in this file. +All notable changes to iNTERCEPT will be documented in this file. + +## [2.9.0] - 2026-01-10 + +### Added +- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline +- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets +- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use +- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory +- **Listening Post Scanner** - Fully functional frequency scanning with signal detection + - Scan button toggles between start/stop states + - Signal hits logged with Listen button to tune directly + - Proper 4-column display (Time, Frequency, Modulation, Action) + +### Changed +- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT" +- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform" +- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu) + - Uses `--system-site-packages` for venv to leverage apt packages + - Added fallback logic when pip fails +- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives + +### Fixed +- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob + - Added restart prevention flags to avoid overlapping restarts + - Increased debounce time for smoother operation + - Added silent mode for programmatic value changes +- **Scanner Signal Hits** - Fixed table column count and colspan +- **Favicon** - Updated to new 'i' logo design + +--- ## [2.0.0] - 2026-01-06 diff --git a/app.py b/app.py index eb069bc..4a79270 100644 --- a/app.py +++ b/app.py @@ -421,6 +421,14 @@ def main() -> None: from routes import register_blueprints register_blueprints(app) + # Initialize WebSocket for audio streaming + try: + from routes.audio_websocket import init_audio_websocket + init_audio_websocket(app) + print("WebSocket audio streaming enabled") + except ImportError as e: + print(f"WebSocket audio disabled (install flask-sock): {e}") + print(f"Open http://localhost:{args.port} in your browser") print() print("Press Ctrl+C to stop") diff --git a/config.py b/config.py index fc37be9..5bd4ec8 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,7 @@ import os import sys # Application version -VERSION = "2.0.0" +VERSION = "2.9.0" def _get_env(key: str, default: str) -> str: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e3b3da2..c87e236 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -14,6 +14,37 @@ pip install -r requirements.txt python3 -m pip install -r requirements.txt ``` +### pip install fails for flask or skyfield + +On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:** + +```bash +# Install Python packages via apt (recommended for Debian/Ubuntu) +sudo apt install python3-flask python3-requests python3-serial python3-skyfield + +# Then create venv with system packages +python3 -m venv --system-site-packages venv +source venv/bin/activate +sudo venv/bin/python intercept.py +``` + +### "error: externally-managed-environment" (pip blocked) + +This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions: + +```bash +# Option 1: Use apt packages (recommended) +sudo apt install python3-flask python3-requests python3-serial python3-skyfield +python3 -m venv --system-site-packages venv +source venv/bin/activate + +# Option 2: Use pipx for isolated install +pipx install flask + +# Option 3: Force pip (not recommended) +pip install --break-system-packages flask +``` + ### "TypeError: 'type' object is not subscriptable" This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.** @@ -33,18 +64,12 @@ pip install -r requirements.txt sudo venv/bin/python intercept.py ``` -### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+) +### Alternative: Use the setup script -Modern systems use PEP 668 to protect system Python. Use a virtual environment: +The setup script handles all installation automatically, including apt packages: ```bash -# Option 1: Virtual environment (recommended) -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -sudo venv/bin/python intercept.py - -# Option 2: Use the setup script (auto-creates venv if needed) +chmod +x setup.sh ./setup.sh ``` @@ -161,6 +186,144 @@ which rx_fm If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported. +### Setting up Icecast for Listening Post Audio + +The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first. + +**Install Icecast:** +```bash +# Ubuntu/Debian +sudo apt install icecast2 + +# macOS +brew install icecast +``` + +**Configure Icecast:** + +During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`: + +```xml + + + + hackme + + your-admin-password + + localhost + + 8000 + + +``` + +**Start Icecast:** +```bash +# Ubuntu/Debian (as service) +sudo systemctl enable icecast2 +sudo systemctl start icecast2 + +# Or run directly +icecast -c /etc/icecast2/icecast.xml + +# macOS +brew services start icecast +# Or: icecast -c /usr/local/etc/icecast.xml +``` + +**Verify Icecast is running:** +- Open http://localhost:8000 in your browser +- You should see the Icecast status page + +**Configure Intercept (optional):** + +The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`: + +```python +scanner_config = { + # ... other settings ... + 'icecast_host': '127.0.0.1', + 'icecast_port': 8000, + 'icecast_mount': '/listen.mp3', + 'icecast_source_password': 'hackme', +} +``` + +**Troubleshooting Icecast:** + +- **"Connection refused" errors**: Ensure Icecast is running on the configured port +- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept +- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active +- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config + +### Audio Streaming Issues - Detailed Debugging + +If the Listening Post shows "Icecast mount not active" errors or audio doesn't play: + +**1. Check the console output for errors** + +Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`: +``` +[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy) +[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues) +``` + +**2. Verify SDR is connected and working** +```bash +# For RTL-SDR +rtl_test -t + +# You should see: "Found 1 device(s)" +# If not, check USB connection and drivers +``` + +**3. Check Icecast password (macOS Homebrew)** + +On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password: +```bash +grep source-password /opt/homebrew/etc/icecast.xml +``` + +If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart: +```bash +brew services restart icecast +``` + +**4. Verify ffmpeg has required codecs** +```bash +# Check MP3 encoder is available +ffmpeg -encoders 2>/dev/null | grep mp3 + +# Should show: libmp3lame +# If not, reinstall ffmpeg with all codecs: +# macOS: brew reinstall ffmpeg +# Linux: sudo apt install ffmpeg +``` + +**5. Test the pipeline manually** + +Try running the audio pipeline directly to see errors: +```bash +# Test rtl_fm (should produce raw audio data) +rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head + +# Test ffmpeg to Icecast (replace PASSWORD with your source password) +rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \ + ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \ + -f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3 +``` + +**6. Common error messages and solutions** + +| Error | Cause | Solution | +|-------|-------|----------| +| `No supported devices found` | SDR not connected | Plug in SDR, check USB | +| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" | +| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config | +| `Connection refused` | Icecast not running | Start Icecast service | +| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs | + ## WiFi Issues ### Monitor mode fails diff --git a/favicon.svg b/favicon.svg index 81b4829..3280665 100644 --- a/favicon.svg +++ b/favicon.svg @@ -1,8 +1,20 @@ - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/promo/instagram-promo.html b/promo/instagram-promo.html new file mode 100644 index 0000000..3cf5aa7 --- /dev/null +++ b/promo/instagram-promo.html @@ -0,0 +1,898 @@ + + + + + + iNTERCEPT Promo + + + + +
+ +
+
+
+
+
+ + +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + +

iNTERCEPT

+

// See the Invisible

+

Signal Intelligence & Counter Surveillance

+
+
+ + +
+
+

Capabilities

+
+
+
📡
+
SDR Scanning
+
Multi-band reception
+
+
+
🔐
+
Decryption
+
Signal analysis
+
+
+
🛰️
+
Tracking
+
Real-time monitoring
+
+
+
🔍
+
Detection
+
Counter surveillance
+
+
+
+
+ + +
+
+
+
+
📟
+
+
PAGER
+
POCSAG & FLEX decoding
+
+
+
+
✈️
+
+
ADS-B
+
Aircraft tracking
+
+
+
+
📻
+
+
LISTENING POST
+
RF monitoring & scanning
+
+
+
+
📶
+
+
WiFi
+
Network reconnaissance
+
+
+
+
🔵
+
+
BLUETOOTH
+
Device & tracker detection
+
+
+
+
🌡️
+
+
SENSORS
+
433MHz IoT decoding
+
+
+
+
🛰️
+
+
SATELLITE
+
Pass prediction & tracking
+
+
+
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + iNTERCEPT +
+
+
+
Messages
+
2,847
+
+
+
Aircraft
+
42
+
+
+
Devices
+
156
+
+
+
+ [14:32:07] + POCSAG + Signal intercepted + 153.350 MHz +
+
+ [14:32:09] + ADS-B + Aircraft detected: BA284 + FL350 +
+
+ [14:32:11] + BT + AirTag detected nearby + -42 dBm +
+
+ [14:32:14] + SENSOR + Temperature: 22.4C + 433.92 MHz +
+
+ [14:32:16] + SCAN + Signal found + 145.500 MHz +
+
+
+
+
+
+ + +
+
+ +

iNTERCEPT

+

See the Invisible

+
Open Source
+

github.com/yourrepo/intercept

+
+
+ + +
+
+
+
+
+
+
+
+ + + + diff --git a/requirements.txt b/requirements.txt index bb7cc85..68a3781 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pyserial>=3.5 # ruff>=0.1.0 # black>=23.0.0 # mypy>=1.0.0 +flask-sock diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py new file mode 100644 index 0000000..1971d88 --- /dev/null +++ b/routes/audio_websocket.py @@ -0,0 +1,256 @@ +"""WebSocket-based audio streaming for SDR.""" + +import subprocess +import threading +import time +import shutil +import json +from flask import Flask + +# Try to import flask-sock +try: + from flask_sock import Sock + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + Sock = None + +from utils.logging import get_logger + +logger = get_logger('intercept.audio_ws') + +# Global state +audio_process = None +rtl_process = None +process_lock = threading.Lock() +current_config = { + 'frequency': 118.0, + 'modulation': 'am', + 'squelch': 0, + 'gain': 40, + 'device': 0 +} + + +def find_rtl_fm(): + return shutil.which('rtl_fm') + + +def find_ffmpeg(): + return shutil.which('ffmpeg') + + +def kill_audio_processes(): + """Kill any running audio processes.""" + global audio_process, rtl_process + + if audio_process: + try: + audio_process.terminate() + audio_process.wait(timeout=0.5) + except: + try: + audio_process.kill() + except: + pass + audio_process = None + + if rtl_process: + try: + rtl_process.terminate() + rtl_process.wait(timeout=0.5) + except: + try: + rtl_process.kill() + except: + pass + rtl_process = None + + # Kill any orphaned processes + try: + subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1) + except: + pass + + time.sleep(0.3) + + +def start_audio_stream(config): + """Start rtl_fm + ffmpeg pipeline, return the ffmpeg process.""" + global audio_process, rtl_process, current_config + + kill_audio_processes() + + rtl_fm = find_rtl_fm() + ffmpeg = find_ffmpeg() + + if not rtl_fm or not ffmpeg: + logger.error("rtl_fm or ffmpeg not found") + return None + + current_config.update(config) + + freq = config.get('frequency', 118.0) + mod = config.get('modulation', 'am') + squelch = config.get('squelch', 0) + gain = config.get('gain', 40) + device = config.get('device', 0) + + # Sample rates based on modulation + if mod == 'wfm': + sample_rate = 170000 + resample_rate = 32000 + elif mod in ['usb', 'lsb']: + sample_rate = 12000 + resample_rate = 12000 + else: + sample_rate = 24000 + resample_rate = 24000 + + freq_hz = int(freq * 1e6) + + rtl_cmd = [ + rtl_fm, + '-M', mod, + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(gain), + '-d', str(device), + '-l', str(squelch), + ] + + # Encode to MP3 for browser compatibility + ffmpeg_cmd = [ + ffmpeg, + '-hide_banner', + '-loglevel', 'error', + '-f', 's16le', + '-ar', str(resample_rate), + '-ac', '1', + '-i', 'pipe:0', + '-acodec', 'libmp3lame', + '-b:a', '128k', + '-f', 'mp3', + '-flush_packets', '1', + 'pipe:1' + ] + + try: + logger.info(f"Starting rtl_fm: {freq} MHz, {mod}") + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + ) + + audio_process = subprocess.Popen( + ffmpeg_cmd, + stdin=rtl_process.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0 + ) + + rtl_process.stdout.close() + + # Check processes started + time.sleep(0.2) + if rtl_process.poll() is not None or audio_process.poll() is not None: + logger.error("Audio process failed to start") + kill_audio_processes() + return None + + return audio_process + + except Exception as e: + logger.error(f"Failed to start audio: {e}") + kill_audio_processes() + return None + + +def init_audio_websocket(app: Flask): + """Initialize WebSocket audio streaming.""" + if not WEBSOCKET_AVAILABLE: + logger.warning("flask-sock not installed, WebSocket audio disabled") + return + + sock = Sock(app) + + @sock.route('/ws/audio') + def audio_stream(ws): + """WebSocket endpoint for audio streaming.""" + logger.info("WebSocket audio client connected") + + proc = None + streaming = False + + try: + while True: + # Check for messages from client (non-blocking with timeout) + try: + msg = ws.receive(timeout=0.01) + if msg: + data = json.loads(msg) + cmd = data.get('cmd') + + if cmd == 'start': + config = data.get('config', {}) + logger.info(f"Starting audio: {config}") + with process_lock: + proc = start_audio_stream(config) + if proc: + streaming = True + ws.send(json.dumps({'status': 'started'})) + else: + ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'})) + + elif cmd == 'stop': + logger.info("Stopping audio") + streaming = False + with process_lock: + kill_audio_processes() + proc = None + ws.send(json.dumps({'status': 'stopped'})) + + elif cmd == 'tune': + # Change frequency/modulation - restart stream + config = data.get('config', {}) + logger.info(f"Retuning: {config}") + with process_lock: + proc = start_audio_stream(config) + if proc: + streaming = True + ws.send(json.dumps({'status': 'tuned'})) + else: + streaming = False + ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'})) + + except TimeoutError: + pass + except Exception as e: + if "timed out" not in str(e).lower(): + logger.error(f"WebSocket receive error: {e}") + + # Stream audio data if active + if streaming and proc and proc.poll() is None: + try: + chunk = proc.stdout.read(4096) + if chunk: + ws.send(chunk) + except Exception as e: + logger.error(f"Audio read error: {e}") + streaming = False + elif streaming: + # Process died + streaming = False + ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'})) + else: + time.sleep(0.01) + + except Exception as e: + logger.info(f"WebSocket closed: {e}") + finally: + with process_lock: + kill_audio_processes() + logger.info("WebSocket audio client disconnected") diff --git a/routes/listening_post.py b/routes/listening_post.py index 6009004..4f71830 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -5,6 +5,8 @@ from __future__ import annotations import json import os import queue +import select +import signal import shutil import subprocess import threading @@ -138,9 +140,6 @@ def scanner_loop(): last_signal_time = 0 signal_detected = False - # Convert step from kHz to MHz - step_mhz = scanner_config['step'] / 1000.0 - try: while scanner_running: # Check if paused @@ -148,6 +147,13 @@ def scanner_loop(): time.sleep(0.1) continue + # Read config values on each iteration (allows live updates) + step_mhz = scanner_config['step'] / 1000.0 + squelch = scanner_config['squelch'] + mod = scanner_config['modulation'] + gain = scanner_config['gain'] + device = scanner_config['device'] + scanner_current_freq = current_freq # Notify clients of frequency change @@ -162,7 +168,6 @@ def scanner_loop(): # Start rtl_fm at this frequency freq_hz = int(current_freq * 1e6) - mod = scanner_config['modulation'] # Sample rates if mod == 'wfm': @@ -182,8 +187,8 @@ def scanner_loop(): '-f', str(freq_hz), '-s', str(sample_rate), '-r', str(resample_rate), - '-g', str(scanner_config['gain']), - '-d', str(scanner_config['device']), + '-g', str(gain), + '-d', str(device), ] # Add bias-t flag if enabled (for external LNA power) if scanner_config.get('bias_t', False): @@ -220,21 +225,22 @@ def scanner_loop(): # Analyze audio level audio_detected = False rms = 0 - threshold = 3000 + threshold = 500 if len(audio_data) > 100: import struct samples = struct.unpack(f'{len(audio_data)//2}h', audio_data) # Calculate RMS level (root mean square) rms = (sum(s*s for s in samples) / len(samples)) ** 0.5 - # WFM (broadcast FM) has much higher audio output - needs higher threshold - # AM/NFM have lower output levels + # Threshold based on squelch setting + # Lower squelch = more sensitive (lower threshold) + # squelch 0 = very sensitive, squelch 100 = only strong signals if mod == 'wfm': - # WFM: threshold 4000-12000 based on squelch - threshold = 4000 + (scanner_config['squelch'] * 80) + # WFM: threshold 500-10000 based on squelch + threshold = 500 + (squelch * 95) else: - # AM/NFM: threshold 1500-8000 based on squelch - threshold = 1500 + (scanner_config['squelch'] * 65) + # AM/NFM: threshold 300-6500 based on squelch + threshold = 300 + (squelch * 62) audio_detected = rms > threshold @@ -425,45 +431,33 @@ def _start_audio_stream(frequency: float, modulation: str): '-ac', '1', '-i', 'pipe:0', '-acodec', 'libmp3lame', + '-b:a', '128k', + '-ar', '44100', '-f', 'mp3', - '-b:a', '96k', - '-ar', '44100', # Resample to standard rate for browser compatibility - '-flush_packets', '1', - '-fflags', '+nobuffer', - '-flags', '+low_delay', 'pipe:1' ] try: - logger.info(f"Starting SDR ({sdr_type.value}): {' '.join(sdr_cmd)}") - audio_rtl_process = subprocess.Popen( - sdr_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + # Use shell pipe for reliable streaming (Python subprocess piping can be unreliable) + shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}" + logger.info(f"Starting audio pipeline: {shell_cmd}") - logger.info(f"Starting ffmpeg: {' '.join(encoder_cmd)}") + audio_rtl_process = None # Not used in shell mode audio_process = subprocess.Popen( - encoder_cmd, - stdin=audio_rtl_process.stdout, + shell_cmd, + shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - bufsize=0 + bufsize=0, + start_new_session=True # Create new process group for clean shutdown ) - audio_rtl_process.stdout.close() - - # Brief delay to check if processes started successfully - time.sleep(0.2) - - if audio_rtl_process.poll() is not None: - stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else '' - logger.error(f"SDR process exited immediately: {stderr}") - return + # Brief delay to check if process started successfully + time.sleep(0.3) if audio_process.poll() is not None: stderr = audio_process.stderr.read().decode() if audio_process.stderr else '' - logger.error(f"ffmpeg exited immediately: {stderr}") + logger.error(f"Audio pipeline exited immediately: {stderr}") return audio_running = True @@ -485,31 +479,38 @@ def _stop_audio_stream_internal(): """Internal stop (must hold lock).""" global audio_process, audio_rtl_process, audio_running, audio_frequency - if audio_process: - try: - audio_process.terminate() - audio_process.wait(timeout=1) - except: - try: - audio_process.kill() - except: - pass - audio_process = None - - if audio_rtl_process: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=1) - except: - try: - audio_rtl_process.kill() - except: - pass - audio_rtl_process = None - + # Set flag first to stop any streaming audio_running = False audio_frequency = 0.0 + # Kill the shell process and its children + if audio_process: + try: + # Kill entire process group (rtl_fm, ffmpeg, shell) + try: + os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + audio_process.kill() + audio_process.wait(timeout=0.5) + except: + pass + + audio_process = None + audio_rtl_process = None + + # Kill any orphaned rtl_fm and ffmpeg processes + try: + subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5) + except: + pass + try: + subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5) + except: + pass + + # Pause for SDR device to be released (important for frequency/modulation changes) + time.sleep(0.7) + # ============================================ # API ENDPOINTS @@ -658,6 +659,42 @@ def skip_signal() -> Response: }) +@listening_post_bp.route('/scanner/config', methods=['POST']) +def update_scanner_config() -> Response: + """Update scanner config while running (step, squelch, gain, dwell).""" + data = request.json or {} + + updated = [] + + if 'step' in data: + scanner_config['step'] = float(data['step']) + updated.append(f"step={data['step']}kHz") + + if 'squelch' in data: + scanner_config['squelch'] = int(data['squelch']) + updated.append(f"squelch={data['squelch']}") + + if 'gain' in data: + scanner_config['gain'] = int(data['gain']) + updated.append(f"gain={data['gain']}") + + if 'dwell_time' in data: + scanner_config['dwell_time'] = int(data['dwell_time']) + updated.append(f"dwell={data['dwell_time']}s") + + if 'modulation' in data: + scanner_config['modulation'] = str(data['modulation']).lower() + updated.append(f"mod={data['modulation']}") + + if updated: + logger.info(f"Scanner config updated: {', '.join(updated)}") + + return jsonify({ + 'status': 'updated', + 'config': scanner_config + }) + + @listening_post_bp.route('/scanner/status') def scanner_status() -> Response: """Get scanner status.""" @@ -738,6 +775,8 @@ def start_audio() -> Response: """Start audio at specific frequency (manual mode).""" global scanner_running + logger.info("Audio start request received") + # Stop scanner if running if scanner_running: scanner_running = False @@ -787,17 +826,15 @@ def start_audio() -> Response: _start_audio_stream(frequency, modulation) if audio_running: - add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})') return jsonify({ 'status': 'started', 'frequency': frequency, - 'modulation': modulation, - 'stream_url': '/listening/audio/stream' + 'modulation': modulation }) else: return jsonify({ 'status': 'error', - 'message': 'Failed to start audio. Check that rtl_fm and ffmpeg are installed, and that an SDR device is connected and not in use by another process.' + 'message': 'Failed to start audio. Check SDR device.' }), 500 @@ -821,28 +858,30 @@ def audio_status() -> Response: @listening_post_bp.route('/audio/stream') def stream_audio() -> Response: """Stream MP3 audio.""" - # Wait briefly for audio to start (handles race condition with /audio/start) - for _ in range(10): + # Wait for audio to be ready (up to 2 seconds for modulation/squelch changes) + for _ in range(40): if audio_running and audio_process: break - time.sleep(0.1) + time.sleep(0.05) if not audio_running or not audio_process: - # Return empty audio response instead of JSON (browser audio element can't parse JSON) return Response(b'', mimetype='audio/mpeg', status=204) def generate(): - chunk_size = 8192 # Larger chunks for smoother streaming try: while audio_running and audio_process and audio_process.poll() is None: - chunk = audio_process.stdout.read(chunk_size) - if not chunk: - # Small wait before checking again to avoid busy loop - time.sleep(0.01) - continue - yield chunk - except Exception as e: - logger.error(f"Audio stream error: {e}") + # Use select to avoid blocking forever + ready, _, _ = select.select([audio_process.stdout], [], [], 2.0) + if ready: + chunk = audio_process.stdout.read(4096) + if chunk: + yield chunk + else: + break + except GeneratorExit: + pass + except: + pass return Response( generate(), diff --git a/setup.sh b/setup.sh index ca213aa..91c7f76 100644 --- a/setup.sh +++ b/setup.sh @@ -199,9 +199,21 @@ install_python_deps() { return 0 fi + # On Debian/Ubuntu, try apt packages first as they're more reliable + if [[ "$OS" == "debian" ]]; then + info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..." + $SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true + + # skyfield may not be available in all distros, try apt first then pip + if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then + warn "python3-skyfield not in apt, will try pip later" + fi + ok "Installed available Python packages via apt" + fi + if [[ ! -d venv ]]; then - python3 -m venv venv - ok "Created venv/" + python3 -m venv --system-site-packages venv + ok "Created venv/ (with system site-packages)" else ok "Using existing venv/" fi @@ -209,12 +221,23 @@ install_python_deps() { # shellcheck disable=SC1091 source venv/bin/activate - python -m pip install --upgrade pip setuptools wheel >/dev/null + python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true ok "Upgraded pip tooling" progress "Installing Python dependencies" - python -m pip install -r requirements.txt - ok "Python dependencies installed" + # Try pip install, but don't fail if apt packages already satisfied deps + if ! python -m pip install -r requirements.txt 2>/dev/null; then + warn "Some pip packages failed - checking if apt packages cover them..." + # Verify critical packages are available + python -c "import flask; import requests" 2>/dev/null || { + fail "Critical Python packages (flask, requests) not installed" + echo "Try: sudo apt install python3-flask python3-requests" + exit 1 + } + ok "Core Python dependencies available" + else + ok "Python dependencies installed" + fi echo } @@ -373,7 +396,7 @@ install_debian_packages() { export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a - TOTAL_STEPS=16 + TOTAL_STEPS=15 CURRENT_STEP=0 progress "Updating APT package lists" @@ -409,8 +432,11 @@ install_debian_packages() { progress "Installing gpsd" apt_install gpsd gpsd-clients || true - progress "Installing Python venv" - apt_install python3-venv || true + progress "Installing Python packages" + apt_install python3-venv python3-pip || true + # Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu) + $SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true + $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true progress "Installing dump1090" if ! cmd_exists dump1090; then diff --git a/static/css/index.css b/static/css/index.css index 52c8b72..48f20c3 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -82,6 +82,228 @@ body { -webkit-font-smoothing: antialiased; } +/* ============================================ + LANDING PAGE / SPLASH SCREEN + ============================================ */ + +.landing-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--bg-primary); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.landing-overlay::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 50% 50%, rgba(74, 158, 255, 0.03) 0%, transparent 50%), + linear-gradient(180deg, transparent 0%, rgba(0, 212, 255, 0.02) 100%); + pointer-events: none; +} + +.landing-content { + text-align: center; + z-index: 1; + animation: landingFadeIn 1s ease-out; +} + +@keyframes landingFadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.landing-logo { + margin-bottom: 30px; + animation: logoPulse 3s ease-in-out infinite; +} + +@keyframes logoPulse { + 0%, 100% { + filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.3)); + } + 50% { + filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.6)); + } +} + +.landing-logo .signal-wave { + animation: signalPulse 2s ease-in-out infinite; +} + +.landing-logo .signal-wave-1 { + animation-delay: 0s; +} + +.landing-logo .signal-wave-2 { + animation-delay: 0.2s; +} + +.landing-logo .signal-wave-3 { + animation-delay: 0.4s; +} + +@keyframes signalPulse { + 0%, 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + +.landing-logo .logo-dot { + animation: dotPulse 1.5s ease-in-out infinite; +} + +@keyframes dotPulse { + 0%, 100% { + fill: #00ff88; + filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); + } + 50% { + fill: #00ffaa; + filter: drop-shadow(0 0 15px rgba(0, 255, 136, 0.9)); + } +} + +.landing-title { + font-family: 'JetBrains Mono', monospace; + font-size: 4rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: 0.3em; + margin: 0 0 10px 0; + text-shadow: 0 0 30px rgba(0, 212, 255, 0.3); +} + +.landing-tagline { + font-family: 'JetBrains Mono', monospace; + font-size: 1.2rem; + color: #00d4ff; + letter-spacing: 0.2em; + margin: 0 0 8px 0; + opacity: 0.9; +} + +.landing-subtitle { + font-family: 'Inter', sans-serif; + font-size: 0.9rem; + color: var(--text-secondary); + letter-spacing: 0.15em; + text-transform: uppercase; + margin: 0 0 40px 0; +} + +.landing-enter-btn { + background: transparent; + border: 2px solid #00d4ff; + color: #00d4ff; + padding: 15px 50px; + font-family: 'JetBrains Mono', monospace; + font-size: 1rem; + letter-spacing: 0.2em; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 15px; + position: relative; + overflow: hidden; +} + +.landing-enter-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.2), transparent); + transition: left 0.5s ease; +} + +.landing-enter-btn:hover::before { + left: 100%; +} + +.landing-enter-btn:hover { + background: rgba(0, 212, 255, 0.1); + box-shadow: 0 0 30px rgba(0, 212, 255, 0.3), inset 0 0 20px rgba(0, 212, 255, 0.1); + transform: scale(1.02); +} + +.landing-enter-btn .btn-icon { + transition: transform 0.3s ease; +} + +.landing-enter-btn:hover .btn-icon { + transform: translateX(5px); +} + +.landing-version { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + color: var(--text-dim); + margin-top: 30px; + letter-spacing: 0.1em; +} + +.landing-scanline { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 3px; + background: linear-gradient(90deg, transparent, #00d4ff, transparent); + animation: scanlineMove 4s linear infinite; + opacity: 0.5; +} + +@keyframes scanlineMove { + 0% { + top: 0; + } + 100% { + top: 100%; + } +} + +.landing-overlay.fade-out { + animation: landingFadeOut 0.5s ease-in forwards; +} + +@keyframes landingFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + visibility: hidden; + } +} + +/* ============================================ + END LANDING PAGE + ============================================ */ + .container { max-width: 100%; margin: 0; @@ -117,7 +339,6 @@ header h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.15em; - text-transform: uppercase; margin: 0; display: inline; vertical-align: middle; @@ -1003,12 +1224,13 @@ header h1 .tagline { .output-content { flex: 1; - padding: 12px; + padding: 10px; overflow-y: auto; font-family: 'JetBrains Mono', monospace; - font-size: 12px; + font-size: 11px; background: var(--bg-primary); - min-height: 200px; /* Ensure minimum space for device cards */ + min-height: 100px; + max-height: 250px; } .output-content::-webkit-scrollbar { @@ -1777,6 +1999,25 @@ header h1 .tagline { display: block; } +/* Satellite Dashboard Embed */ +.satellite-dashboard-embed { + width: 100%; + height: calc(100vh - 200px); + min-height: 700px; + max-height: 900px; + background: var(--bg-primary); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.satellite-dashboard-embed iframe { + width: 100%; + height: 100%; + border: none; + background: var(--bg-primary); +} + /* Satellite Pass Predictor - Cool UI */ .pass-predictor { display: grid; @@ -3558,3 +3799,922 @@ body::before { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.5; transform: scale(0.8); } } + +/* ============================================ + RADIO CONTROL INTERFACE - Listening Post + Modern tactical radio-inspired UI + ============================================ */ + +/* Radio Panel Container */ +.radio-panel { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +/* Frequency Display Section */ +.radio-display-section { + display: flex; + justify-content: center; + padding: 15px 0; +} + +.freq-display { + background: rgba(0, 0, 0, 0.8); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 25px 40px; + text-align: center; + box-shadow: + inset 0 0 40px rgba(0, 0, 0, 0.6), + 0 0 30px var(--accent-cyan-dim); + position: relative; + overflow: hidden; +} + +.freq-display::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + var(--accent-cyan) 50%, + transparent 100% + ); + opacity: 0.5; +} + +.freq-status { + font-family: 'Orbitron', monospace; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 3px; + margin-bottom: 8px; +} + +.freq-status.active { + color: var(--accent-cyan); + animation: freq-status-pulse 1.5s ease-in-out infinite; +} + +.freq-status.signal { + color: var(--accent-green); +} + +@keyframes freq-status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.freq-digits { + font-family: 'JetBrains Mono', monospace; + font-size: 56px; + font-weight: 700; + color: var(--accent-cyan); + text-shadow: + 0 0 10px var(--accent-cyan), + 0 0 25px var(--accent-cyan), + 0 0 50px rgba(74, 158, 255, 0.4); + letter-spacing: 2px; + line-height: 1; +} + +.freq-digits.signal { + color: var(--accent-green); + text-shadow: + 0 0 10px var(--accent-green), + 0 0 25px var(--accent-green), + 0 0 50px rgba(34, 197, 94, 0.4); +} + +.freq-unit { + font-family: 'JetBrains Mono', monospace; + font-size: 20px; + color: var(--text-secondary); + margin-left: 8px; + vertical-align: baseline; +} + +.freq-mode-badge { + display: inline-block; + padding: 6px 16px; + background: var(--accent-cyan-dim); + border: 1px solid var(--accent-cyan); + border-radius: 4px; + font-family: 'Orbitron', monospace; + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); + margin-top: 12px; + letter-spacing: 1px; +} + +/* Radio Controls Section */ +.radio-controls-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + padding: 20px; + background: var(--bg-primary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.radio-control-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.radio-control-label { + font-family: 'Orbitron', monospace; + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 2px; +} + +/* Rotary Knob */ +.radio-knob { + width: 70px; + height: 70px; + background: linear-gradient(145deg, var(--bg-elevated), var(--bg-primary)); + border-radius: 50%; + border: 2px solid var(--border-light); + position: relative; + cursor: grab; + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.4), + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px var(--accent-cyan-dim); + transition: box-shadow 0.2s ease; +} + +.radio-knob:hover { + box-shadow: + inset 0 2px 6px rgba(0, 0, 0, 0.4), + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 30px var(--accent-cyan-dim); +} + +.radio-knob:active { + cursor: grabbing; +} + +.radio-knob::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + background: radial-gradient(circle, var(--bg-tertiary) 0%, var(--bg-primary) 100%); + border-radius: 50%; + transform: translate(-50%, -50%); + border: 1px solid var(--border-color); +} + +.radio-knob::after { + content: ''; + position: absolute; + width: 3px; + height: 22px; + background: var(--accent-cyan); + top: 6px; + left: 50%; + transform: translateX(-50%); + border-radius: 2px; + box-shadow: 0 0 8px var(--accent-cyan); +} + +.radio-knob.active::after { + background: var(--accent-green); + box-shadow: 0 0 8px var(--accent-green); +} + +/* Knob tick marks ring */ +.knob-ticks { + position: absolute; + width: 90px; + height: 90px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.knob-tick { + position: absolute; + width: 2px; + height: 6px; + background: var(--border-light); + top: 0; + left: 50%; + transform-origin: center 45px; +} + +.knob-tick.major { + height: 8px; + background: var(--text-muted); +} + +.knob-label { + font-family: 'Orbitron', monospace; + font-size: 9px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 4px; +} + +.knob-value { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--accent-cyan); + text-shadow: 0 0 10px var(--accent-cyan-dim); +} + +/* Knobs Row Container */ +.knobs-row { + display: flex; + gap: 30px; + justify-content: center; +} + +.knob-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +/* Button Bank */ +.radio-button-bank { + display: flex; + gap: 2px; + background: var(--bg-primary); + padding: 4px; + border-radius: 6px; + border: 1px solid var(--border-color); +} + +.radio-btn { + padding: 10px 14px; + background: var(--bg-secondary); + border: 1px solid transparent; + color: var(--text-secondary); + font-family: 'Orbitron', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + transition: all 0.15s ease; + min-width: 50px; + text-align: center; +} + +.radio-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.radio-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.radio-btn:only-child { + border-radius: 4px; +} + +.radio-btn.active { + background: var(--accent-cyan); + color: var(--bg-primary); + box-shadow: 0 0 15px var(--accent-cyan-dim); +} + +.radio-btn:hover:not(.active) { + background: var(--bg-elevated); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +/* Tuning Dial */ +.tuning-dial { + width: 100px; + height: 100px; + background: conic-gradient( + from 0deg, + var(--bg-secondary) 0deg, + var(--bg-elevated) 45deg, + var(--bg-secondary) 90deg, + var(--bg-elevated) 135deg, + var(--bg-secondary) 180deg, + var(--bg-elevated) 225deg, + var(--bg-secondary) 270deg, + var(--bg-elevated) 315deg, + var(--bg-secondary) 360deg + ); + border-radius: 50%; + border: 3px solid var(--border-light); + position: relative; + cursor: grab; + box-shadow: + 0 4px 20px rgba(0, 0, 0, 0.4), + inset 0 0 30px rgba(0, 0, 0, 0.4); +} + +.tuning-dial:active { + cursor: grabbing; +} + +.tuning-dial::after { + content: ''; + position: absolute; + top: 4px; + left: 50%; + width: 4px; + height: 12px; + background: var(--accent-cyan); + transform: translateX(-50%); + border-radius: 2px; + box-shadow: 0 0 10px var(--accent-cyan); +} + +/* Fine Tune Buttons */ +.fine-tune-btns { + display: flex; + gap: 4px; + margin-top: 8px; +} + +.tune-btn { + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.tune-btn:hover { + background: var(--bg-elevated); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.tune-btn:active { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +/* Arc Signal Meter */ +.signal-arc-meter { + width: 180px; + height: 100px; + position: relative; +} + +.signal-arc-meter svg { + width: 100%; + height: 100%; +} + +.signal-arc-bg { + fill: none; + stroke: var(--bg-primary); + stroke-width: 12; + stroke-linecap: round; +} + +.signal-arc-fill { + fill: none; + stroke: url(#signal-gradient); + stroke-width: 12; + stroke-linecap: round; + stroke-dasharray: 188.5; + stroke-dashoffset: 188.5; + transition: stroke-dashoffset 0.1s ease-out; +} + +.signal-arc-needle { + fill: var(--accent-cyan); + filter: drop-shadow(0 0 4px var(--accent-cyan)); + transform-origin: 90px 90px; + transition: transform 0.1s ease-out; +} + +.signal-arc-center { + fill: var(--bg-secondary); + stroke: var(--border-color); + stroke-width: 1; +} + +.signal-arc-label { + font-family: 'JetBrains Mono', monospace; + font-size: 8px; + fill: var(--text-muted); +} + +.signal-arc-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + fill: var(--accent-cyan); + text-anchor: middle; +} + +/* Scanner Section */ +.radio-scanner-section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 15px; + background: var(--bg-primary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.scanner-range { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; +} + +.scanner-range input { + width: 100px; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--accent-cyan); + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + text-align: center; +} + +.scanner-range input:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px var(--accent-cyan-dim); +} + +.scanner-range span { + font-family: 'Orbitron', monospace; + font-size: 12px; + color: var(--text-muted); +} + +.scanner-buttons { + display: flex; + gap: 8px; + justify-content: center; +} + +.radio-action-btn { + padding: 12px 24px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-family: 'Orbitron', monospace; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.radio-action-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.radio-action-btn.scan { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: var(--bg-primary); +} + +.radio-action-btn.scan:hover { + background: #5aa8ff; + box-shadow: 0 0 20px var(--accent-cyan-dim); +} + +.radio-action-btn.scan.active { + background: var(--accent-red); + border-color: var(--accent-red); +} + +.radio-action-btn.scan.active:hover { + box-shadow: 0 0 20px var(--accent-red-dim); +} + +.radio-action-btn.pause { + background: var(--accent-orange); + border-color: var(--accent-orange); + color: #000; +} + +.radio-action-btn.pause:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Scan Progress Bar */ +.scan-progress { + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + margin-top: 8px; +} + +.scan-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); + border-radius: 3px; + transition: width 0.2s ease-out; + box-shadow: 0 0 10px var(--accent-cyan); +} + +/* Activity Log Section */ +.radio-log-section { + background: var(--bg-primary); + border-radius: 8px; + border: 1px solid var(--border-color); + overflow: hidden; +} + +.log-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.log-header span { + font-family: 'Orbitron', monospace; + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 2px; +} + +.log-toggle { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 4px 8px; +} + +.log-toggle:hover { + color: var(--accent-cyan); +} + +.log-content { + max-height: 200px; + overflow-y: auto; + padding: 10px 15px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +/* Radio Meters Section */ +.radio-meters-section { + display: flex; + justify-content: center; + align-items: center; + gap: 30px; + padding: 15px; +} + +.spectrum-display { + flex: 1; + max-width: 400px; +} + +.spectrum-display canvas { + width: 100%; + height: 80px; + background: rgba(0, 0, 0, 0.4); + border-radius: 6px; + border: 1px solid var(--border-color); +} + +/* Responsive adjustments for radio panel */ +@media (max-width: 768px) { + .radio-controls-section { + flex-direction: column; + align-items: center; + gap: 20px; + } + + .knobs-row { + flex-wrap: wrap; + } + + .freq-digits { + font-size: 40px; + } + + .radio-button-bank { + flex-wrap: wrap; + justify-content: center; + } +} + +/* ============================================ + RADIO MODULE BOX SYSTEM + Compact modular layout for Listening Post + ============================================ */ + +.radio-module-box { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + position: relative; +} + +.radio-module-box::before { + content: ''; + position: absolute; + top: 0; + left: 10px; + right: 10px; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-cyan-dim), transparent); +} + +/* Main Scanner Module - Primary Feature */ +.radio-module-box.scanner-main { + background: linear-gradient(180deg, var(--bg-secondary) 0%, rgba(0,20,30,0.95) 100%); + border: 1px solid var(--accent-cyan-dim); + box-shadow: 0 0 20px rgba(0, 212, 255, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.3); +} + +.radio-module-box.scanner-main::before { + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); +} + +.radio-module-box.scanner-main .module-header { + color: var(--accent-cyan); + font-size: 13px; + letter-spacing: 3px; +} + +.module-header { + font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 8px; +} + +/* Compact Radio Button Bank */ +.radio-button-bank.compact { + gap: 2px; +} + +.radio-button-bank.compact .radio-btn { + padding: 5px 10px; + font-size: 10px; +} + +/* Radio Input Fields */ +.radio-input { + background: rgba(0, 0, 0, 0.4); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + padding: 6px 8px; + text-align: center; +} + +.radio-input:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: 0 0 5px var(--accent-cyan-dim); +} + +/* Smaller Action Buttons for Compact Layout */ +.radio-action-btn { + padding: 6px 12px; + font-size: 10px; + font-family: 'Orbitron', monospace; + font-weight: 600; + text-transform: uppercase; + background: var(--bg-elevated); + border: 1px solid var(--accent-cyan); + color: var(--accent-cyan); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; + flex: 1; +} + +.radio-action-btn:hover:not(:disabled) { + background: var(--accent-cyan); + color: var(--bg-primary); + box-shadow: 0 0 10px var(--accent-cyan-dim); +} + +.radio-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.radio-action-btn.scan { + background: var(--accent-green); + border-color: var(--accent-green); + color: #000; +} + +.radio-action-btn.scan:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(0, 255, 136, 0.4); +} + +/* Statistics Box */ +.stat-box { + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + padding: 10px; + text-align: center; +} + +.stat-value { + font-family: 'JetBrains Mono', monospace; + font-size: 22px; + font-weight: bold; +} + +.stat-label { + font-size: 9px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 2px; +} + +/* Smaller Knobs for Compact Layout */ +.radio-module-box .radio-knob { + width: 55px; + height: 55px; +} + +.radio-module-box .knob-container { + text-align: center; +} + +.radio-module-box .knob-label { + font-size: 9px; + margin-top: 5px; +} + +.radio-module-box .knob-value { + font-size: 11px; +} + +/* Smaller Tuning Dial for Compact Layout */ +.radio-module-box .tuning-dial { + width: 90px; + height: 90px; +} + +/* Fine Tune Buttons - Compact */ +.fine-tune-btns { + display: flex; + gap: 4px; + justify-content: center; +} + +.tune-btn { + padding: 4px 8px; + font-size: 10px; + font-family: 'JetBrains Mono', monospace; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 3px; + cursor: pointer; + transition: all 0.15s; +} + +.tune-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +/* Signal Hits Table Compact */ +.radio-module-box table th, +.radio-module-box table td { + padding: 4px 6px; + border-bottom: 1px solid var(--border-color); +} + +.radio-module-box table tbody tr:hover { + background: rgba(0, 212, 255, 0.05); +} + +/* Log Content Compact */ +.radio-module-box .log-content { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding: 8px; + font-family: 'JetBrains Mono', monospace; +} + +/* Listening Mode Selector Buttons */ +.radio-mode-btn { + padding: 12px 24px; + font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + background: var(--bg-elevated); + border: 2px solid var(--border-color); + color: var(--text-secondary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 10px; +} + +.radio-mode-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(0, 212, 255, 0.05); +} + +.radio-mode-btn.active { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(0, 255, 136, 0.1)); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + box-shadow: 0 0 20px rgba(0, 212, 255, 0.2), inset 0 0 20px rgba(0, 212, 255, 0.05); +} + +/* Listening Mode Panels */ +.listening-mode-panel { + display: contents; +} + +.listening-mode-panel[style*="display: none"] { + display: none !important; +} + +/* Frequency Preset Buttons */ +.preset-freq-btn { + padding: 8px 14px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + color: var(--text-secondary); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.preset-freq-btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(0, 212, 255, 0.1); +} + +.preset-freq-btn:active { + transform: scale(0.98); +} diff --git a/static/css/satellite_dashboard.css b/static/css/satellite_dashboard.css index fcaa3a6..12b4425 100644 --- a/static/css/satellite_dashboard.css +++ b/static/css/satellite_dashboard.css @@ -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; } \ No newline at end of file diff --git a/static/img/intercept-logo-dark.svg b/static/img/intercept-logo-dark.svg new file mode 100644 index 0000000..19ab081 --- /dev/null +++ b/static/img/intercept-logo-dark.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/intercept-logo.svg b/static/img/intercept-logo.svg new file mode 100644 index 0000000..cf1b993 --- /dev/null +++ b/static/img/intercept-logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/components/radio-knob.js b/static/js/components/radio-knob.js new file mode 100644 index 0000000..d6b6db8 --- /dev/null +++ b/static/js/components/radio-knob.js @@ -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 }; +} diff --git a/static/js/core/app.js b/static/js/core/app.js new file mode 100644 index 0000000..e8a8ee4 --- /dev/null +++ b/static/js/core/app.js @@ -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 = '' + 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); diff --git a/static/js/core/audio.js b/static/js/core/audio.js new file mode 100644 index 0000000..2b58c0e --- /dev/null +++ b/static/js/core/audio.js @@ -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 }); diff --git a/static/js/core/utils.js b/static/js/core/utils.js new file mode 100644 index 0000000..e741de3 --- /dev/null +++ b/static/js/core/utils.js @@ -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, '>'); + 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; +} diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js new file mode 100644 index 0000000..905b0b6 --- /dev/null +++ b/static/js/modes/listening-post.js @@ -0,0 +1,2194 @@ +/** + * Intercept - Listening Post Mode + * Frequency scanner and manual audio receiver + */ + +// ============== STATE ============== + +let isScannerRunning = false; +let isScannerPaused = false; +let scannerEventSource = null; +let scannerSignalCount = 0; +let scannerLogEntries = []; +let scannerFreqsScanned = 0; +let scannerCycles = 0; +let scannerStartFreq = 118; +let scannerEndFreq = 137; +let scannerSignalActive = false; + +// Audio state +let isAudioPlaying = false; +let audioToolsAvailable = { rtl_fm: false, ffmpeg: false }; +let audioReconnectAttempts = 0; +const MAX_AUDIO_RECONNECT = 3; + +// WebSocket audio state +let audioWebSocket = null; +let audioQueue = []; +let isWebSocketAudio = false; + +// Visualizer state +let visualizerContext = null; +let visualizerAnalyser = null; +let visualizerSource = null; +let visualizerAnimationId = null; +let peakLevel = 0; +let peakDecay = 0.95; + +// Track recent signal hits to prevent duplicates +let recentSignalHits = new Map(); + +// Direct listen state +let isDirectListening = false; +let currentModulation = 'am'; + +// ============== PRESETS ============== + +const scannerPresets = { + fm: { start: 88, end: 108, step: 200, mod: 'wfm' }, + air: { start: 118, end: 137, step: 25, mod: 'am' }, + marine: { start: 156, end: 163, step: 25, mod: 'fm' }, + amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' }, + pager: { start: 152, end: 160, step: 25, mod: 'fm' }, + amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' } +}; + +const audioPresets = { + fm: { freq: 98.1, mod: 'wfm' }, + airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency + marine: { freq: 156.8, mod: 'fm' }, // Channel 16 - distress + amateur2m: { freq: 146.52, mod: 'fm' }, // 2m calling frequency + amateur70cm: { freq: 446.0, mod: 'fm' } +}; + +// ============== SCANNER TOOLS CHECK ============== + +function checkScannerTools() { + fetch('/listening/tools') + .then(r => r.json()) + .then(data => { + const warnings = []; + if (!data.rtl_fm) { + warnings.push('rtl_fm not found - install rtl-sdr tools'); + } + if (!data.ffmpeg) { + warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); + } + + const warningDiv = document.getElementById('scannerToolsWarning'); + const warningText = document.getElementById('scannerToolsWarningText'); + if (warningDiv && warnings.length > 0) { + warningText.innerHTML = warnings.join('
'); + warningDiv.style.display = 'block'; + document.getElementById('scannerStartBtn').disabled = true; + document.getElementById('scannerStartBtn').style.opacity = '0.5'; + } else if (warningDiv) { + warningDiv.style.display = 'none'; + document.getElementById('scannerStartBtn').disabled = false; + document.getElementById('scannerStartBtn').style.opacity = '1'; + } + }) + .catch(() => {}); +} + +// ============== SCANNER HELPERS ============== + +/** + * Get the currently selected device from the global SDR selector + */ +function getSelectedDevice() { + const select = document.getElementById('deviceSelect'); + return parseInt(select?.value || '0'); +} + +/** + * Get the currently selected SDR type from the global selector + */ +function getSelectedSDRTypeForScanner() { + const select = document.getElementById('sdrTypeSelect'); + return select?.value || 'rtlsdr'; +} + +// ============== SCANNER PRESETS ============== + +function applyScannerPreset() { + const preset = document.getElementById('scannerPreset').value; + if (preset !== 'custom' && scannerPresets[preset]) { + const p = scannerPresets[preset]; + document.getElementById('scannerStartFreq').value = p.start; + document.getElementById('scannerEndFreq').value = p.end; + document.getElementById('scannerStep').value = p.step; + document.getElementById('scannerModulation').value = p.mod; + } +} + +// ============== SCANNER CONTROLS ============== + +function toggleScanner() { + if (isScannerRunning) { + stopScanner(); + } else { + startScanner(); + } +} + +function startScanner() { + // Use unified radio controls - read all current UI values + const startFreq = parseFloat(document.getElementById('radioScanStart')?.value || 118); + const endFreq = parseFloat(document.getElementById('radioScanEnd')?.value || 137); + const stepSelect = document.getElementById('radioScanStep'); + const step = stepSelect ? parseFloat(stepSelect.value) : 25; + const modulation = currentModulation || 'am'; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + const dwellSelect = document.getElementById('radioScanDwell'); + const dwell = dwellSelect ? parseInt(dwellSelect.value) : 10; + const device = getSelectedDevice(); + + if (startFreq >= endFreq) { + if (typeof showNotification === 'function') { + showNotification('Scanner Error', 'End frequency must be greater than start'); + } + return; + } + + // Check if device is available + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('scanner')) { + return; + } + + // Store scanner range for progress calculation + scannerStartFreq = startFreq; + scannerEndFreq = endFreq; + scannerFreqsScanned = 0; + scannerCycles = 0; + + // Update sidebar display + updateScannerDisplay('STARTING...', 'var(--accent-orange)'); + + // Show progress bars + const progressEl = document.getElementById('scannerProgress'); + if (progressEl) { + progressEl.style.display = 'block'; + document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1); + document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1); + } + + const mainProgress = document.getElementById('mainScannerProgress'); + if (mainProgress) { + mainProgress.style.display = 'block'; + document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz'; + document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz'; + } + + fetch('/listening/scanner/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start_freq: startFreq, + end_freq: endFreq, + step: step, + modulation: modulation, + squelch: squelch, + gain: gain, + dwell_time: dwell, + device: device, + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false + }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'started') { + if (typeof reserveDevice === 'function') reserveDevice(device, 'scanner'); + isScannerRunning = true; + isScannerPaused = false; + scannerSignalActive = false; + + // Update controls (with null checks) + const startBtn = document.getElementById('scannerStartBtn'); + if (startBtn) { + startBtn.textContent = 'Stop Scanner'; + startBtn.classList.add('active'); + } + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) pauseBtn.disabled = false; + + // Update radio scan button to show STOP + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = '⏹ STOP'; + radioScanBtn.style.background = 'var(--accent-red)'; + radioScanBtn.style.borderColor = 'var(--accent-red)'; + } + + updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Scanning...'; + + // Show level meter + const levelMeter = document.getElementById('scannerLevelMeter'); + if (levelMeter) levelMeter.style.display = 'block'; + + connectScannerStream(); + addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`); + if (typeof showNotification === 'function') { + showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`); + } + } else { + updateScannerDisplay('ERROR', 'var(--accent-red)'); + if (typeof showNotification === 'function') { + showNotification('Scanner Error', data.message || 'Failed to start'); + } + } + }) + .catch(err => { + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'ERROR'; + updateScannerDisplay('ERROR', 'var(--accent-red)'); + if (typeof showNotification === 'function') { + showNotification('Scanner Error', err.message); + } + }); +} + +function stopScanner() { + fetch('/listening/scanner/stop', { method: 'POST' }) + .then(() => { + if (typeof releaseDevice === 'function') releaseDevice('scanner'); + isScannerRunning = false; + isScannerPaused = false; + scannerSignalActive = false; + + // Update sidebar (with null checks) + const startBtn = document.getElementById('scannerStartBtn'); + if (startBtn) { + startBtn.textContent = 'Start Scanner'; + startBtn.classList.remove('active'); + } + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) { + pauseBtn.disabled = true; + pauseBtn.textContent = '⏸ Pause'; + } + + // Update radio scan button + const radioScanBtn = document.getElementById('radioScanBtn'); + if (radioScanBtn) { + radioScanBtn.innerHTML = '📡 SCAN'; + radioScanBtn.style.background = ''; + radioScanBtn.style.borderColor = ''; + } + + updateScannerDisplay('STOPPED', 'var(--text-muted)'); + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.textContent = '---.--- MHz'; + const modLabel = document.getElementById('scannerModLabel'); + if (modLabel) modLabel.textContent = '--'; + + const progressEl = document.getElementById('scannerProgress'); + if (progressEl) progressEl.style.display = 'none'; + + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'none'; + + const levelMeter = document.getElementById('scannerLevelMeter'); + if (levelMeter) levelMeter.style.display = 'none'; + + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Ready'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) { + mainModeLabel.textContent = 'SCANNER STOPPED'; + document.getElementById('mainScannerFreq').textContent = '---.---'; + document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)'; + document.getElementById('mainScannerMod').textContent = '--'; + } + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'none'; + + const mainProgress = document.getElementById('mainScannerProgress'); + if (mainProgress) mainProgress.style.display = 'none'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'none'; + + // Stop scanner audio + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + scannerAudio.pause(); + scannerAudio.src = ''; + } + + if (scannerEventSource) { + scannerEventSource.close(); + scannerEventSource = null; + } + addScannerLogEntry('Scanner stopped', ''); + }) + .catch(() => {}); +} + +function pauseScanner() { + const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause'; + fetch(endpoint, { method: 'POST' }) + .then(r => r.json()) + .then(data => { + isScannerPaused = !isScannerPaused; + const pauseBtn = document.getElementById('scannerPauseBtn'); + if (pauseBtn) pauseBtn.textContent = isScannerPaused ? '▶ Resume' : '⏸ Pause'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) { + statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + statusText.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } + + const activityStatus = document.getElementById('scannerActivityStatus'); + if (activityStatus) { + activityStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + activityStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) { + mainModeLabel.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + } + + addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', ''); + }) + .catch(() => {}); +} + +function skipSignal() { + if (!isScannerRunning) { + if (typeof showNotification === 'function') { + showNotification('Scanner', 'Scanner is not running'); + } + return; + } + + fetch('/listening/scanner/skip', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.status === 'skipped' && typeof showNotification === 'function') { + showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`); + } + }) + .catch(err => { + if (typeof showNotification === 'function') { + showNotification('Skip Error', err.message); + } + }); +} + +// ============== SCANNER STREAM ============== + +function connectScannerStream() { + if (scannerEventSource) { + scannerEventSource.close(); + } + + scannerEventSource = new EventSource('/listening/scanner/stream'); + + scannerEventSource.onmessage = function(e) { + try { + const data = JSON.parse(e.data); + handleScannerEvent(data); + } catch (err) { + console.warn('Scanner parse error:', err); + } + }; + + scannerEventSource.onerror = function() { + if (isScannerRunning) { + setTimeout(connectScannerStream, 2000); + } + }; +} + +function handleScannerEvent(data) { + switch (data.type) { + case 'freq_change': + case 'scan_update': + handleFrequencyUpdate(data); + break; + case 'signal_found': + handleSignalFound(data); + break; + case 'signal_lost': + case 'signal_skipped': + handleSignalLost(data); + break; + case 'log': + if (data.entry && data.entry.type === 'scan_cycle') { + scannerCycles++; + const cyclesEl = document.getElementById('mainScanCycles'); + if (cyclesEl) cyclesEl.textContent = scannerCycles; + } + break; + case 'stopped': + stopScanner(); + break; + } +} + +function handleFrequencyUpdate(data) { + const freqStr = data.frequency.toFixed(3); + + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.textContent = freqStr + ' MHz'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = freqStr; + + // Update progress bar + const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100; + const progressBar = document.getElementById('scannerProgressBar'); + if (progressBar) progressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; + + const mainProgressBar = document.getElementById('mainProgressBar'); + if (mainProgressBar) mainProgressBar.style.width = Math.max(0, Math.min(100, progress)) + '%'; + + scannerFreqsScanned++; + const freqsEl = document.getElementById('mainFreqsScanned'); + if (freqsEl) freqsEl.textContent = scannerFreqsScanned; + + // Update level meter if present + if (data.level !== undefined) { + const levelPercent = Math.min(100, (data.level / 5000) * 100); + const levelBar = document.getElementById('scannerLevelBar'); + if (levelBar) { + levelBar.style.width = levelPercent + '%'; + if (data.detected) { + levelBar.style.background = 'var(--accent-green)'; + } else if (data.level > (data.threshold || 0) * 0.7) { + levelBar.style.background = 'var(--accent-orange)'; + } else { + levelBar.style.background = 'var(--accent-cyan)'; + } + } + const levelValue = document.getElementById('scannerLevelValue'); + if (levelValue) levelValue.textContent = data.level; + } + + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = `${freqStr} MHz${data.level !== undefined ? ` (level: ${data.level})` : ''}`; +} + +function handleSignalFound(data) { + scannerSignalCount++; + scannerSignalActive = true; + const freqStr = data.frequency.toFixed(3); + + const signalCount = document.getElementById('scannerSignalCount'); + if (signalCount) signalCount.textContent = scannerSignalCount; + const mainSignalCount = document.getElementById('mainSignalCount'); + if (mainSignalCount) mainSignalCount.textContent = scannerSignalCount; + + // Update sidebar + updateScannerDisplay('SIGNAL FOUND', 'var(--accent-green)'); + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'block'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Listening to signal...'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = 'SIGNAL DETECTED'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = 'var(--accent-green)'; + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'none'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'block'; + + // Start audio playback for the detected signal + if (data.audio_streaming) { + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + // Pass the signal frequency and modulation to getStreamUrl + const streamUrl = getStreamUrl(data.frequency, data.modulation); + console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz'); + scannerAudio.src = streamUrl; + scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e)); + } + } + + // Add to sidebar recent signals + if (typeof addSidebarRecentSignal === 'function') { + addSidebarRecentSignal(data.frequency, data.modulation); + } + + addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal'); + addSignalHit(data); + + if (typeof showNotification === 'function') { + showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`); + } +} + +function handleSignalLost(data) { + scannerSignalActive = false; + + // Update sidebar + updateScannerDisplay('SCANNING', 'var(--accent-cyan)'); + const signalPanel = document.getElementById('scannerSignalPanel'); + if (signalPanel) signalPanel.style.display = 'none'; + const statusText = document.getElementById('scannerStatusText'); + if (statusText) statusText.textContent = 'Scanning...'; + + // Update main display + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = 'SCANNING'; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = 'var(--accent-cyan)'; + + const mainAnim = document.getElementById('mainScannerAnimation'); + if (mainAnim) mainAnim.style.display = 'block'; + + const mainSignalAlert = document.getElementById('mainSignalAlert'); + if (mainSignalAlert) mainSignalAlert.style.display = 'none'; + + // Stop audio + const scannerAudio = document.getElementById('scannerAudioPlayer'); + if (scannerAudio) { + scannerAudio.pause(); + scannerAudio.src = ''; + } + + const logType = data.type === 'signal_skipped' ? 'info' : 'info'; + const logTitle = data.type === 'signal_skipped' ? 'Signal skipped' : 'Signal lost'; + addScannerLogEntry(logTitle, `${data.frequency.toFixed(3)} MHz`, logType); +} + +function updateScannerDisplay(mode, color) { + const modeLabel = document.getElementById('scannerModeLabel'); + if (modeLabel) { + modeLabel.textContent = mode; + modeLabel.style.color = color; + } + + const currentFreq = document.getElementById('scannerCurrentFreq'); + if (currentFreq) currentFreq.style.color = color; + + const mainModeLabel = document.getElementById('mainScannerModeLabel'); + if (mainModeLabel) mainModeLabel.textContent = mode; + + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.style.color = color; +} + +// ============== SCANNER LOG ============== + +function addScannerLogEntry(title, detail, type = 'info') { + const now = new Date(); + const timestamp = now.toLocaleTimeString(); + const entry = { timestamp, title, detail, type }; + scannerLogEntries.unshift(entry); + + if (scannerLogEntries.length > 100) { + scannerLogEntries.pop(); + } + + // Color based on type + const getTypeColor = (t) => { + switch(t) { + case 'signal': return 'var(--accent-green)'; + case 'error': return 'var(--accent-red)'; + default: return 'var(--text-secondary)'; + } + }; + + // Update sidebar log + const sidebarLog = document.getElementById('scannerLog'); + if (sidebarLog) { + sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e => + `
+ [${e.timestamp}] + ${e.title} ${e.detail} +
` + ).join(''); + } + + // Update main activity log + const activityLog = document.getElementById('scannerActivityLog'); + if (activityLog) { + const getBorderColor = (t) => { + switch(t) { + case 'signal': return 'var(--accent-green)'; + case 'error': return 'var(--accent-red)'; + default: return 'var(--border-color)'; + } + }; + activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e => + `
+ [${e.timestamp}] + ${e.title} + ${e.detail} +
` + ).join(''); + } +} + +function addSignalHit(data) { + const tbody = document.getElementById('scannerHitsBody'); + if (!tbody) return; + + const now = Date.now(); + const freqKey = data.frequency.toFixed(3); + + // Check for duplicate + if (recentSignalHits.has(freqKey)) { + const lastHit = recentSignalHits.get(freqKey); + if (now - lastHit < 5000) return; + } + recentSignalHits.set(freqKey, now); + + // Clean up old entries + for (const [freq, time] of recentSignalHits) { + if (now - time > 30000) { + recentSignalHits.delete(freq); + } + } + + const timestamp = new Date().toLocaleTimeString(); + + if (tbody.innerHTML.includes('No signals detected')) { + tbody.innerHTML = ''; + } + + const mod = data.modulation || 'fm'; + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid var(--border-color)'; + row.innerHTML = ` + ${timestamp} + ${data.frequency.toFixed(3)} + ${mod.toUpperCase()} + + + + `; + tbody.insertBefore(row, tbody.firstChild); + + while (tbody.children.length > 50) { + tbody.removeChild(tbody.lastChild); + } + + const hitCount = document.getElementById('scannerHitCount'); + if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`; +} + +function clearScannerLog() { + scannerLogEntries = []; + scannerSignalCount = 0; + scannerFreqsScanned = 0; + scannerCycles = 0; + recentSignalHits.clear(); + + const signalCount = document.getElementById('scannerSignalCount'); + if (signalCount) signalCount.textContent = '0'; + + const mainSignalCount = document.getElementById('mainSignalCount'); + if (mainSignalCount) mainSignalCount.textContent = '0'; + + const mainFreqsScanned = document.getElementById('mainFreqsScanned'); + if (mainFreqsScanned) mainFreqsScanned.textContent = '0'; + + const mainScanCycles = document.getElementById('mainScanCycles'); + if (mainScanCycles) mainScanCycles.textContent = '0'; + + const sidebarLog = document.getElementById('scannerLog'); + if (sidebarLog) sidebarLog.innerHTML = '
Scanner activity will appear here...
'; + + const activityLog = document.getElementById('scannerActivityLog'); + if (activityLog) activityLog.innerHTML = '
Waiting for scanner to start...
'; + + const hitsBody = document.getElementById('scannerHitsBody'); + if (hitsBody) hitsBody.innerHTML = 'No signals detected'; + + const hitCount = document.getElementById('scannerHitCount'); + if (hitCount) hitCount.textContent = '0 signals found'; +} + +function exportScannerLog() { + if (scannerLogEntries.length === 0) { + if (typeof showNotification === 'function') { + showNotification('Export', 'No log entries to export'); + } + return; + } + + const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e => + `"${e.timestamp}","${e.title}","${e.detail}"` + ).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `scanner_log_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + + if (typeof showNotification === 'function') { + showNotification('Export', 'Log exported to CSV'); + } +} + +// ============== AUDIO TOOLS CHECK ============== + +function checkAudioTools() { + fetch('/listening/tools') + .then(r => r.json()) + .then(data => { + audioToolsAvailable.rtl_fm = data.rtl_fm; + audioToolsAvailable.ffmpeg = data.ffmpeg; + + // Only rtl_fm/rx_fm + ffmpeg are required for direct streaming + const warnings = []; + if (!data.rtl_fm && !data.rx_fm) { + warnings.push('rtl_fm/rx_fm not found - install rtl-sdr or soapysdr-tools'); + } + if (!data.ffmpeg) { + warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)'); + } + + const warningDiv = document.getElementById('audioToolsWarning'); + const warningText = document.getElementById('audioToolsWarningText'); + if (warningDiv) { + if (warnings.length > 0) { + warningText.innerHTML = warnings.join('
'); + warningDiv.style.display = 'block'; + document.getElementById('audioStartBtn').disabled = true; + document.getElementById('audioStartBtn').style.opacity = '0.5'; + } else { + warningDiv.style.display = 'none'; + document.getElementById('audioStartBtn').disabled = false; + document.getElementById('audioStartBtn').style.opacity = '1'; + } + } + }) + .catch(() => {}); +} + +// ============== AUDIO PRESETS ============== + +function applyAudioPreset() { + const preset = document.getElementById('audioPreset').value; + const freqInput = document.getElementById('audioFrequency'); + const modSelect = document.getElementById('audioModulation'); + + if (audioPresets[preset]) { + freqInput.value = audioPresets[preset].freq; + modSelect.value = audioPresets[preset].mod; + } +} + +// ============== AUDIO CONTROLS ============== + +function toggleAudio() { + if (isAudioPlaying) { + stopAudio(); + } else { + startAudio(); + } +} + +function startAudio() { + const frequency = parseFloat(document.getElementById('audioFrequency').value); + const modulation = document.getElementById('audioModulation').value; + const squelch = parseInt(document.getElementById('audioSquelch').value); + const gain = parseInt(document.getElementById('audioGain').value); + const device = getSelectedDevice(); + + if (isNaN(frequency) || frequency <= 0) { + if (typeof showNotification === 'function') { + showNotification('Audio Error', 'Invalid frequency'); + } + return; + } + + // Check if device is in use + if (typeof getDeviceInUseBy === 'function') { + const usedBy = getDeviceInUseBy(device); + if (usedBy && usedBy !== 'audio') { + if (typeof showNotification === 'function') { + showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}.`); + } + return; + } + } + + document.getElementById('audioStatus').textContent = 'STARTING...'; + document.getElementById('audioStatus').style.color = 'var(--accent-orange)'; + + // Use direct streaming - no Icecast needed + if (typeof reserveDevice === 'function') reserveDevice(device, 'audio'); + isAudioPlaying = true; + + // Build direct stream URL with parameters + const streamUrl = `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; + console.log('Connecting to direct stream:', streamUrl); + + // Start browser audio playback + const audioPlayer = document.getElementById('audioPlayer'); + audioPlayer.src = streamUrl; + audioPlayer.volume = document.getElementById('audioVolume').value / 100; + + initAudioVisualizer(); + + audioPlayer.onplaying = () => { + document.getElementById('audioStatus').textContent = 'STREAMING'; + document.getElementById('audioStatus').style.color = 'var(--accent-green)'; + }; + + audioPlayer.onerror = (e) => { + console.error('Audio player error:', e); + document.getElementById('audioStatus').textContent = 'ERROR'; + document.getElementById('audioStatus').style.color = 'var(--accent-red)'; + if (typeof showNotification === 'function') { + showNotification('Audio Error', 'Stream error - check SDR connection'); + } + }; + + audioPlayer.play().catch(e => { + console.warn('Audio autoplay blocked:', e); + if (typeof showNotification === 'function') { + showNotification('Audio Ready', 'Click Play button again if audio does not start'); + } + }); + + document.getElementById('audioStartBtn').textContent = '⏹ Stop Audio'; + document.getElementById('audioStartBtn').classList.add('active'); + document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')'; + document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device; + + if (typeof showNotification === 'function') { + showNotification('Audio Started', `Streaming ${frequency} MHz to browser`); + } +} + +async function stopAudio() { + stopAudioVisualizer(); + + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.pause(); + audioPlayer.src = ''; + } + + try { + await fetch('/listening/audio/stop', { method: 'POST' }); + if (typeof releaseDevice === 'function') releaseDevice('audio'); + isAudioPlaying = false; + document.getElementById('audioStartBtn').textContent = '▶ Play Audio'; + document.getElementById('audioStartBtn').classList.remove('active'); + document.getElementById('audioStatus').textContent = 'STOPPED'; + document.getElementById('audioStatus').style.color = 'var(--text-muted)'; + document.getElementById('audioDeviceStatus').textContent = '--'; + } catch (e) { + console.error('Error stopping audio:', e); + } +} + +function updateAudioVolume() { + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.volume = document.getElementById('audioVolume').value / 100; + } +} + +function audioFreqUp() { + const input = document.getElementById('audioFrequency'); + const mod = document.getElementById('audioModulation').value; + const step = (mod === 'wfm') ? 0.2 : 0.025; + input.value = (parseFloat(input.value) + step).toFixed(2); + if (isAudioPlaying) { + tuneAudioFrequency(parseFloat(input.value)); + } +} + +function audioFreqDown() { + const input = document.getElementById('audioFrequency'); + const mod = document.getElementById('audioModulation').value; + const step = (mod === 'wfm') ? 0.2 : 0.025; + input.value = (parseFloat(input.value) - step).toFixed(2); + if (isAudioPlaying) { + tuneAudioFrequency(parseFloat(input.value)); + } +} + +function tuneAudioFrequency(frequency) { + fetch('/listening/audio/tune', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frequency: frequency }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'tuned') { + document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz'; + } + }) + .catch(() => { + stopAudio(); + setTimeout(startAudio, 300); + }); +} + +async function tuneToFrequency(freq, mod) { + try { + // Stop scanner if running + if (isScannerRunning) { + stopScanner(); + await new Promise(resolve => setTimeout(resolve, 300)); + } + + // Update frequency input + const freqInput = document.getElementById('radioScanStart'); + if (freqInput) { + freqInput.value = freq.toFixed(1); + } + + // Update modulation if provided + if (mod) { + setModulation(mod); + } + + // Update tuning dial (silent to avoid duplicate events) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(freq, true); + } + + // Update frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) { + mainFreq.textContent = freq.toFixed(3); + } + + // Start listening immediately + await startDirectListenImmediate(); + + if (typeof showNotification === 'function') { + showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz (${(mod || currentModulation).toUpperCase()})`); + } + } catch (err) { + console.error('Error tuning to frequency:', err); + if (typeof showNotification === 'function') { + showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message); + } + } +} + +// ============== AUDIO VISUALIZER ============== + +function initAudioVisualizer() { + const audioPlayer = document.getElementById('audioPlayer'); + if (!audioPlayer) return; + + if (!visualizerContext) { + visualizerContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + if (visualizerContext.state === 'suspended') { + visualizerContext.resume(); + } + + if (!visualizerSource) { + try { + visualizerSource = visualizerContext.createMediaElementSource(audioPlayer); + visualizerAnalyser = visualizerContext.createAnalyser(); + visualizerAnalyser.fftSize = 256; + visualizerAnalyser.smoothingTimeConstant = 0.7; + + visualizerSource.connect(visualizerAnalyser); + visualizerAnalyser.connect(visualizerContext.destination); + } catch (e) { + console.warn('Could not create audio source:', e); + return; + } + } + + const container = document.getElementById('audioVisualizerContainer'); + if (container) container.style.display = 'block'; + + drawAudioVisualizer(); +} + +function drawAudioVisualizer() { + if (!visualizerAnalyser) return; + + const canvas = document.getElementById('audioSpectrumCanvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const bufferLength = visualizerAnalyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + function draw() { + visualizerAnimationId = requestAnimationFrame(draw); + + visualizerAnalyser.getByteFrequencyData(dataArray); + + let sum = 0; + for (let i = 0; i < bufferLength; i++) { + sum += dataArray[i]; + } + const average = sum / bufferLength; + const levelPercent = (average / 255) * 100; + + if (levelPercent > peakLevel) { + peakLevel = levelPercent; + } else { + peakLevel *= peakDecay; + } + + const meterFill = document.getElementById('audioSignalMeter'); + const meterPeak = document.getElementById('audioSignalPeak'); + const meterValue = document.getElementById('audioSignalValue'); + + if (meterFill) meterFill.style.width = levelPercent + '%'; + if (meterPeak) meterPeak.style.left = Math.min(peakLevel, 100) + '%'; + + const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60; + if (meterValue) meterValue.textContent = db + ' dB'; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const barWidth = canvas.width / bufferLength * 2.5; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + const barHeight = (dataArray[i] / 255) * canvas.height; + const hue = 200 - (i / bufferLength) * 60; + const lightness = 40 + (dataArray[i] / 255) * 30; + ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`; + ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight); + x += barWidth; + } + + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.font = '8px JetBrains Mono'; + ctx.fillText('0', 2, canvas.height - 2); + ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2); + ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2); + } + + draw(); +} + +function stopAudioVisualizer() { + if (visualizerAnimationId) { + cancelAnimationFrame(visualizerAnimationId); + visualizerAnimationId = null; + } + + const meterFill = document.getElementById('audioSignalMeter'); + const meterPeak = document.getElementById('audioSignalPeak'); + const meterValue = document.getElementById('audioSignalValue'); + + if (meterFill) meterFill.style.width = '0%'; + if (meterPeak) meterPeak.style.left = '0%'; + if (meterValue) meterValue.textContent = '-∞ dB'; + + peakLevel = 0; + + const container = document.getElementById('audioVisualizerContainer'); + if (container) container.style.display = 'none'; +} + +// ============== RADIO KNOB CONTROLS ============== + +/** + * Update scanner config on the backend (for live updates while scanning) + */ +function updateScannerConfig(config) { + if (!isScannerRunning) return; + fetch('/listening/scanner/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }).catch(() => {}); +} + +/** + * Initialize radio knob controls and wire them to scanner parameters + */ +function initRadioKnobControls() { + // Squelch knob + const squelchKnob = document.getElementById('radioSquelchKnob'); + if (squelchKnob) { + squelchKnob.addEventListener('knobchange', function(e) { + const value = Math.round(e.detail.value); + const valueDisplay = document.getElementById('radioSquelchValue'); + if (valueDisplay) valueDisplay.textContent = value; + // Sync with scanner + updateScannerConfig({ squelch: value }); + // Restart stream if direct listening (squelch requires restart) + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Gain knob + const gainKnob = document.getElementById('radioGainKnob'); + if (gainKnob) { + gainKnob.addEventListener('knobchange', function(e) { + const value = Math.round(e.detail.value); + const valueDisplay = document.getElementById('radioGainValue'); + if (valueDisplay) valueDisplay.textContent = value; + // Sync with scanner + updateScannerConfig({ gain: value }); + // Restart stream if direct listening (gain requires restart) + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Volume knob - controls scanner audio player volume + const volumeKnob = document.getElementById('radioVolumeKnob'); + if (volumeKnob) { + volumeKnob.addEventListener('knobchange', function(e) { + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (audioPlayer) { + audioPlayer.volume = e.detail.value / 100; + } + const manualPlayer = document.getElementById('audioPlayer'); + if (manualPlayer) { + manualPlayer.volume = e.detail.value / 100; + } + // Update knob value display + const valueDisplay = document.getElementById('radioVolumeValue'); + if (valueDisplay) valueDisplay.textContent = Math.round(e.detail.value); + }); + } + + // Main Tuning dial - updates frequency display and inputs + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial) { + mainTuningDial.addEventListener('knobchange', function(e) { + const freq = e.detail.value; + // Update main frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) { + mainFreq.textContent = freq.toFixed(3); + } + // Update radio scan start input + const startFreqInput = document.getElementById('radioScanStart'); + if (startFreqInput) { + startFreqInput.value = freq.toFixed(1); + } + // Update sidebar frequency input + const sidebarFreq = document.getElementById('audioFrequency'); + if (sidebarFreq) { + sidebarFreq.value = freq.toFixed(3); + } + // If currently listening, retune to new frequency + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Legacy tuning dial support + const tuningDial = document.getElementById('tuningDial'); + if (tuningDial) { + tuningDial.addEventListener('knobchange', function(e) { + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = e.detail.value.toFixed(3); + const startFreqInput = document.getElementById('radioScanStart'); + if (startFreqInput) startFreqInput.value = e.detail.value.toFixed(1); + // If currently listening, retune to new frequency + if (isDirectListening) { + startDirectListen(); + } + }); + } + + // Sync radio scan range inputs with sidebar + const radioScanStart = document.getElementById('radioScanStart'); + const radioScanEnd = document.getElementById('radioScanEnd'); + + if (radioScanStart) { + radioScanStart.addEventListener('change', function() { + const sidebarStart = document.getElementById('scanStartFreq'); + if (sidebarStart) sidebarStart.value = this.value; + // Restart stream if direct listening + if (isDirectListening) { + startDirectListen(); + } + }); + } + + if (radioScanEnd) { + radioScanEnd.addEventListener('change', function() { + const sidebarEnd = document.getElementById('scanEndFreq'); + if (sidebarEnd) sidebarEnd.value = this.value; + }); + } +} + +/** + * Set modulation mode (called from HTML onclick) + */ +function setModulation(mod) { + // Update sidebar select + const modSelect = document.getElementById('scanModulation'); + if (modSelect) modSelect.value = mod; + + // Update audio modulation select + const audioMod = document.getElementById('audioModulation'); + if (audioMod) audioMod.value = mod; + + // Update button states in radio panel + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + + // Update main display badge + const mainBadge = document.getElementById('mainScannerMod'); + if (mainBadge) mainBadge.textContent = mod.toUpperCase(); +} + +/** + * Set band preset (called from HTML onclick) + */ +function setBand(band) { + const preset = scannerPresets[band]; + if (!preset) return; + + // Update button states + document.querySelectorAll('#bandBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.band === band); + }); + + // Update sidebar frequency inputs + const sidebarStart = document.getElementById('scanStartFreq'); + const sidebarEnd = document.getElementById('scanEndFreq'); + if (sidebarStart) sidebarStart.value = preset.start; + if (sidebarEnd) sidebarEnd.value = preset.end; + + // Update radio panel frequency inputs + const radioStart = document.getElementById('radioScanStart'); + const radioEnd = document.getElementById('radioScanEnd'); + if (radioStart) radioStart.value = preset.start; + if (radioEnd) radioEnd.value = preset.end; + + // Update tuning dial range and value (silent to avoid triggering restart) + const tuningDial = document.getElementById('tuningDial'); + if (tuningDial && tuningDial._dial) { + tuningDial._dial.min = preset.start; + tuningDial._dial.max = preset.end; + tuningDial._dial.setValue(preset.start, true); + } + + // Update main frequency display + const mainFreq = document.getElementById('mainScannerFreq'); + if (mainFreq) mainFreq.textContent = preset.start.toFixed(3); + + // Update modulation + setModulation(preset.mod); + + // Update main range display if scanning + const rangeStart = document.getElementById('mainRangeStart'); + const rangeEnd = document.getElementById('mainRangeEnd'); + if (rangeStart) rangeStart.textContent = preset.start; + if (rangeEnd) rangeEnd.textContent = preset.end; + + // Store for scanner use + scannerStartFreq = preset.start; + scannerEndFreq = preset.end; +} + +// ============== SYNTHESIZER VISUALIZATION ============== + +let synthAnimationId = null; +let synthCanvas = null; +let synthCtx = null; +let synthBars = []; +const SYNTH_BAR_COUNT = 32; + +function initSynthesizer() { + synthCanvas = document.getElementById('synthesizerCanvas'); + if (!synthCanvas) return; + + // Set canvas size + const rect = synthCanvas.parentElement.getBoundingClientRect(); + synthCanvas.width = rect.width - 20; + synthCanvas.height = 60; + + synthCtx = synthCanvas.getContext('2d'); + + // Initialize bar heights + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + synthBars[i] = { height: 0, targetHeight: 0, velocity: 0 }; + } + + drawSynthesizer(); +} + +function drawSynthesizer() { + if (!synthCtx || !synthCanvas) return; + + const width = synthCanvas.width; + const height = synthCanvas.height; + const barWidth = (width / SYNTH_BAR_COUNT) - 2; + + // Clear canvas + synthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)'; + synthCtx.fillRect(0, 0, width, height); + + // Determine activity level based on state + let activityLevel = 0; + if (isScannerRunning && !isScannerPaused) { + activityLevel = scannerSignalActive ? 0.9 : 0.4; + } else if (isDirectListening) { + activityLevel = 0.7; + } + + // Update bar targets + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + if (activityLevel > 0) { + // Create wave-like pattern with some randomness + const wave = Math.sin((Date.now() / 200) + (i * 0.3)) * 0.3; + const random = Math.random() * 0.4; + const centerBoost = 1 - Math.abs((i - SYNTH_BAR_COUNT / 2) / (SYNTH_BAR_COUNT / 2)) * 0.5; + synthBars[i].targetHeight = (wave + random + 0.3) * activityLevel * centerBoost * height; + } else { + // Idle state - minimal activity + synthBars[i].targetHeight = (Math.sin((Date.now() / 500) + (i * 0.5)) * 0.1 + 0.1) * height * 0.3; + } + + // Smooth animation + const diff = synthBars[i].targetHeight - synthBars[i].height; + synthBars[i].velocity += diff * 0.1; + synthBars[i].velocity *= 0.8; + synthBars[i].height += synthBars[i].velocity; + synthBars[i].height = Math.max(2, Math.min(height - 4, synthBars[i].height)); + } + + // Draw bars + for (let i = 0; i < SYNTH_BAR_COUNT; i++) { + const x = i * (barWidth + 2) + 1; + const barHeight = synthBars[i].height; + const y = (height - barHeight) / 2; + + // Color gradient based on height and state + let hue, saturation, lightness; + if (scannerSignalActive) { + hue = 120; // Green for signal + saturation = 80; + lightness = 40 + (barHeight / height) * 30; + } else if (isScannerRunning || isDirectListening) { + hue = 190 + (i / SYNTH_BAR_COUNT) * 30; // Cyan to blue + saturation = 80; + lightness = 35 + (barHeight / height) * 25; + } else { + hue = 200; + saturation = 50; + lightness = 25 + (barHeight / height) * 15; + } + + const gradient = synthCtx.createLinearGradient(x, y, x, y + barHeight); + gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); + gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`); + gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 20}%, 0.9)`); + + synthCtx.fillStyle = gradient; + synthCtx.fillRect(x, y, barWidth, barHeight); + + // Add glow effect for active bars + if (barHeight > height * 0.5 && activityLevel > 0.5) { + synthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`; + synthCtx.shadowBlur = 8; + synthCtx.fillRect(x, y, barWidth, barHeight); + synthCtx.shadowBlur = 0; + } + } + + // Draw center line + synthCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)'; + synthCtx.lineWidth = 1; + synthCtx.beginPath(); + synthCtx.moveTo(0, height / 2); + synthCtx.lineTo(width, height / 2); + synthCtx.stroke(); + + synthAnimationId = requestAnimationFrame(drawSynthesizer); +} + +function stopSynthesizer() { + if (synthAnimationId) { + cancelAnimationFrame(synthAnimationId); + synthAnimationId = null; + } +} + +// ============== INITIALIZATION ============== + +/** + * Get the audio stream URL with parameters + * Streams directly from Flask - no Icecast needed + */ +function getStreamUrl(freq, mod) { + const frequency = freq || parseFloat(document.getElementById('radioScanStart')?.value) || 118.0; + const modulation = mod || currentModulation || 'am'; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + return `/listening/audio/stream?freq=${frequency}&mod=${modulation}&squelch=${squelch}&gain=${gain}&t=${Date.now()}`; +} + +function initListeningPost() { + checkScannerTools(); + checkAudioTools(); + + // WebSocket audio disabled for now - using HTTP streaming + // initWebSocketAudio(); + + // Initialize synthesizer visualization + initSynthesizer(); + + // Initialize radio knobs if the component is available + if (typeof initRadioKnobs === 'function') { + initRadioKnobs(); + } + + // Connect radio knobs to scanner controls + initRadioKnobControls(); + + // Step dropdown - sync with scanner when changed + const stepSelect = document.getElementById('radioScanStep'); + if (stepSelect) { + stepSelect.addEventListener('change', function() { + const step = parseFloat(this.value); + console.log('[SCANNER] Step changed to:', step, 'kHz'); + updateScannerConfig({ step: step }); + }); + } + + // Dwell dropdown - sync with scanner when changed + const dwellSelect = document.getElementById('radioScanDwell'); + if (dwellSelect) { + dwellSelect.addEventListener('change', function() { + const dwell = parseInt(this.value); + console.log('[SCANNER] Dwell changed to:', dwell, 's'); + updateScannerConfig({ dwell_time: dwell }); + }); + } + + // Set up audio player error handling + const audioPlayer = document.getElementById('audioPlayer'); + if (audioPlayer) { + audioPlayer.addEventListener('error', function(e) { + console.warn('Audio player error:', e); + if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) { + audioReconnectAttempts++; + setTimeout(() => { + audioPlayer.src = getStreamUrl(); + audioPlayer.play().catch(() => {}); + }, 500); + } + }); + + audioPlayer.addEventListener('stalled', function() { + if (isAudioPlaying) { + audioPlayer.load(); + audioPlayer.play().catch(() => {}); + } + }); + + audioPlayer.addEventListener('playing', function() { + audioReconnectAttempts = 0; + }); + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initListeningPost); + +// ============== UNIFIED RADIO CONTROLS ============== + +/** + * Toggle direct listen mode (tune to start frequency and listen) + */ +function toggleDirectListen() { + console.log('[LISTEN] toggleDirectListen called, isDirectListening:', isDirectListening); + if (isDirectListening) { + stopDirectListen(); + } else { + // First press - start immediately, don't debounce + startDirectListenImmediate(); + } +} + +// Debounce for startDirectListen +let listenDebounceTimer = null; +// Flag to prevent overlapping restart attempts +let isRestarting = false; +// Flag indicating another restart is needed after current one finishes +let restartPending = false; +// Debounce for frequency tuning (user might be scrolling through) +// Needs to be long enough for SDR to fully release between restarts +const TUNE_DEBOUNCE_MS = 600; + +/** + * Start direct listening - debounced for frequency changes + */ +function startDirectListen() { + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + } + listenDebounceTimer = setTimeout(async () => { + // If already restarting, mark that we need another restart when done + if (isRestarting) { + console.log('[LISTEN] Restart in progress, will retry after'); + restartPending = true; + return; + } + + await _startDirectListenInternal(); + + // If another restart was requested during this one, do it now + while (restartPending) { + restartPending = false; + console.log('[LISTEN] Processing pending restart'); + await _startDirectListenInternal(); + } + }, TUNE_DEBOUNCE_MS); +} + +/** + * Start listening immediately (no debounce) - for button press + */ +async function startDirectListenImmediate() { + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + listenDebounceTimer = null; + } + restartPending = false; // Clear any pending + if (isRestarting) { + console.log('[LISTEN] Waiting for current restart to finish...'); + // Wait for current restart to complete (max 5 seconds) + let waitCount = 0; + while (isRestarting && waitCount < 50) { + await new Promise(r => setTimeout(r, 100)); + waitCount++; + } + } + await _startDirectListenInternal(); +} + +// ============== WEBSOCKET AUDIO ============== + +/** + * Initialize WebSocket audio connection + */ +function initWebSocketAudio() { + if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { + return audioWebSocket; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/audio`; + + console.log('[WS-AUDIO] Connecting to:', wsUrl); + audioWebSocket = new WebSocket(wsUrl); + audioWebSocket.binaryType = 'arraybuffer'; + + audioWebSocket.onopen = () => { + console.log('[WS-AUDIO] Connected'); + isWebSocketAudio = true; + }; + + audioWebSocket.onclose = () => { + console.log('[WS-AUDIO] Disconnected'); + isWebSocketAudio = false; + audioWebSocket = null; + }; + + audioWebSocket.onerror = (e) => { + console.error('[WS-AUDIO] Error:', e); + isWebSocketAudio = false; + }; + + audioWebSocket.onmessage = (event) => { + if (typeof event.data === 'string') { + // JSON message (status updates) + try { + const msg = JSON.parse(event.data); + console.log('[WS-AUDIO] Status:', msg); + if (msg.status === 'error') { + addScannerLogEntry('Audio error: ' + msg.message, '', 'error'); + } + } catch (e) {} + } else { + // Binary data (audio) + handleWebSocketAudioData(event.data); + } + }; + + return audioWebSocket; +} + +/** + * Handle incoming WebSocket audio data + */ +function handleWebSocketAudioData(data) { + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (!audioPlayer) return; + + // Use MediaSource API to stream audio + if (!audioPlayer.msSource) { + setupMediaSource(audioPlayer); + } + + if (audioPlayer.sourceBuffer && !audioPlayer.sourceBuffer.updating) { + try { + audioPlayer.sourceBuffer.appendBuffer(new Uint8Array(data)); + } catch (e) { + // Buffer full or other error, skip this chunk + } + } else { + // Queue data for later + audioQueue.push(new Uint8Array(data)); + if (audioQueue.length > 50) audioQueue.shift(); // Prevent memory buildup + } +} + +/** + * Setup MediaSource for streaming audio + */ +function setupMediaSource(audioPlayer) { + if (!window.MediaSource) { + console.warn('[WS-AUDIO] MediaSource not supported'); + return; + } + + const mediaSource = new MediaSource(); + audioPlayer.src = URL.createObjectURL(mediaSource); + audioPlayer.msSource = mediaSource; + + mediaSource.addEventListener('sourceopen', () => { + try { + const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg'); + audioPlayer.sourceBuffer = sourceBuffer; + + sourceBuffer.addEventListener('updateend', () => { + // Process queued data + if (audioQueue.length > 0 && !sourceBuffer.updating) { + try { + sourceBuffer.appendBuffer(audioQueue.shift()); + } catch (e) {} + } + }); + } catch (e) { + console.error('[WS-AUDIO] Failed to create source buffer:', e); + } + }); +} + +/** + * Send command over WebSocket + */ +function sendWebSocketCommand(cmd, config = {}) { + if (!audioWebSocket || audioWebSocket.readyState !== WebSocket.OPEN) { + initWebSocketAudio(); + // Wait for connection and retry + setTimeout(() => sendWebSocketCommand(cmd, config), 500); + return; + } + + audioWebSocket.send(JSON.stringify({ cmd, config })); +} + +async function _startDirectListenInternal() { + console.log('[LISTEN] _startDirectListenInternal called'); + + // Prevent overlapping restarts + if (isRestarting) { + console.log('[LISTEN] Already restarting, skipping'); + return; + } + isRestarting = true; + + try { + if (isScannerRunning) { + stopScanner(); + } + + const freqInput = document.getElementById('radioScanStart'); + const freq = freqInput ? parseFloat(freqInput.value) : 118.0; + const squelch = parseInt(document.getElementById('radioSquelchValue')?.textContent) || 30; + const gain = parseInt(document.getElementById('radioGainValue')?.textContent) || 40; + + console.log('[LISTEN] Tuning to:', freq, 'MHz', currentModulation); + + const listenBtn = document.getElementById('radioListenBtn'); + if (listenBtn) { + listenBtn.innerHTML = '⏳ TUNING...'; + listenBtn.style.background = 'var(--accent-orange)'; + listenBtn.style.borderColor = 'var(--accent-orange)'; + } + + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (!audioPlayer) { + addScannerLogEntry('Audio player not found', '', 'error'); + updateDirectListenUI(false); + return; + } + + // Fully reset audio element to clean state + audioPlayer.oncanplay = null; // Remove old handler + try { + audioPlayer.pause(); + } catch (e) {} + audioPlayer.removeAttribute('src'); + audioPlayer.load(); // Reset the element + + // Start audio on backend (it handles stopping old stream) + const response = await fetch('/listening/audio/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + frequency: freq, + modulation: currentModulation, + squelch: squelch, + gain: gain + }) + }); + + const result = await response.json(); + console.log('[LISTEN] Backend:', result.status); + + if (result.status !== 'started') { + console.error('[LISTEN] Failed:', result.message); + addScannerLogEntry('Failed: ' + (result.message || 'Unknown error'), '', 'error'); + isDirectListening = false; + updateDirectListenUI(false); + return; + } + + // Wait for stream to be ready (backend needs time after restart) + await new Promise(r => setTimeout(r, 300)); + + // Connect to new stream + const streamUrl = `/listening/audio/stream?t=${Date.now()}`; + console.log('[LISTEN] Connecting to stream:', streamUrl); + audioPlayer.src = streamUrl; + + // Wait for audio to be ready then play + audioPlayer.oncanplay = () => { + console.log('[LISTEN] Audio can play'); + audioPlayer.play().catch(e => console.warn('[LISTEN] Autoplay blocked:', e)); + }; + + // Also try to play immediately (some browsers need this) + audioPlayer.play().catch(e => { + console.log('[LISTEN] Initial play blocked, waiting for canplay'); + }); + + isDirectListening = true; + updateDirectListenUI(true, freq); + addScannerLogEntry(`${freq.toFixed(3)} MHz (${currentModulation.toUpperCase()})`, '', 'signal'); + + } catch (e) { + console.error('[LISTEN] Error:', e); + addScannerLogEntry('Error: ' + e.message, '', 'error'); + isDirectListening = false; + updateDirectListenUI(false); + } finally { + isRestarting = false; + } +} + +/** + * Stop direct listening + */ +function stopDirectListen() { + console.log('[LISTEN] Stopping'); + + // Clear all pending state + if (listenDebounceTimer) { + clearTimeout(listenDebounceTimer); + listenDebounceTimer = null; + } + restartPending = false; + + const audioPlayer = document.getElementById('scannerAudioPlayer'); + if (audioPlayer) { + audioPlayer.pause(); + // Clear MediaSource if using WebSocket + if (audioPlayer.msSource) { + try { + audioPlayer.msSource.endOfStream(); + } catch (e) {} + audioPlayer.msSource = null; + audioPlayer.sourceBuffer = null; + } + audioPlayer.src = ''; + } + audioQueue = []; + + // Stop via WebSocket if connected + if (audioWebSocket && audioWebSocket.readyState === WebSocket.OPEN) { + sendWebSocketCommand('stop'); + } + + // Also stop via HTTP (fallback) + fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {}); + + isDirectListening = false; + updateDirectListenUI(false); + addScannerLogEntry('Listening stopped'); +} + +/** + * Update UI for direct listen mode + */ +function updateDirectListenUI(isPlaying, freq) { + const listenBtn = document.getElementById('radioListenBtn'); + const statusLabel = document.getElementById('mainScannerModeLabel'); + const freqDisplay = document.getElementById('mainScannerFreq'); + const quickStatus = document.getElementById('lpQuickStatus'); + const quickFreq = document.getElementById('lpQuickFreq'); + + if (listenBtn) { + if (isPlaying) { + listenBtn.innerHTML = '⏹ STOP'; + listenBtn.style.background = 'var(--accent-red)'; + listenBtn.style.borderColor = 'var(--accent-red)'; + } else { + listenBtn.innerHTML = '🎧 LISTEN'; + listenBtn.style.background = 'var(--accent-purple)'; + listenBtn.style.borderColor = 'var(--accent-purple)'; + } + } + + if (statusLabel) { + statusLabel.textContent = isPlaying ? 'LISTENING' : 'STOPPED'; + statusLabel.style.color = isPlaying ? 'var(--accent-green)' : 'var(--text-muted)'; + } + + if (freqDisplay && freq) { + freqDisplay.textContent = freq.toFixed(3); + } + + if (quickStatus) { + quickStatus.textContent = isPlaying ? 'LISTENING' : 'IDLE'; + quickStatus.style.color = isPlaying ? 'var(--accent-green)' : 'var(--accent-cyan)'; + } + + if (quickFreq && freq) { + quickFreq.textContent = freq.toFixed(3) + ' MHz'; + } +} + +/** + * Tune frequency by delta + */ +function tuneFreq(delta) { + const freqInput = document.getElementById('radioScanStart'); + if (freqInput) { + let newFreq = parseFloat(freqInput.value) + delta; + newFreq = Math.max(24, Math.min(1800, newFreq)); + freqInput.value = newFreq.toFixed(1); + + // Update display + const freqDisplay = document.getElementById('mainScannerFreq'); + if (freqDisplay) { + freqDisplay.textContent = newFreq.toFixed(3); + } + + // Update tuning dial position (silent to avoid duplicate restart) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(newFreq, true); + } + + const quickFreq = document.getElementById('lpQuickFreq'); + if (quickFreq) { + quickFreq.textContent = newFreq.toFixed(3) + ' MHz'; + } + + // If currently listening, restart stream at new frequency + if (isDirectListening) { + startDirectListen(); + } + } +} + +/** + * Quick tune to a preset frequency + */ +function quickTune(freq, mod) { + // Update frequency inputs + const startInput = document.getElementById('radioScanStart'); + if (startInput) { + startInput.value = freq; + } + + // Update modulation (don't trigger auto-restart here, we'll handle it below) + if (mod) { + currentModulation = mod; + // Update modulation UI without triggering restart + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + const badge = document.getElementById('mainScannerMod'); + if (badge) { + const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; + badge.textContent = modLabels[mod] || mod.toUpperCase(); + } + } + + // Update display + const freqDisplay = document.getElementById('mainScannerFreq'); + if (freqDisplay) { + freqDisplay.textContent = freq.toFixed(3); + } + + // Update tuning dial position (silent to avoid duplicate restart) + const mainTuningDial = document.getElementById('mainTuningDial'); + if (mainTuningDial && mainTuningDial._dial) { + mainTuningDial._dial.setValue(freq, true); + } + + const quickFreq = document.getElementById('lpQuickFreq'); + if (quickFreq) { + quickFreq.textContent = freq.toFixed(3) + ' MHz'; + } + + addScannerLogEntry(`Quick tuned to ${freq.toFixed(3)} MHz (${mod.toUpperCase()})`); + + // If currently listening, restart immediately (this is a deliberate preset selection) + if (isDirectListening) { + startDirectListenImmediate(); + } +} + +/** + * Enhanced setModulation to also update currentModulation + * Uses immediate restart if currently listening + */ +const originalSetModulation = window.setModulation; +window.setModulation = function(mod) { + console.log('[MODULATION] Setting modulation to:', mod, 'isListening:', isDirectListening); + currentModulation = mod; + + // Update modulation button states + document.querySelectorAll('#modBtnBank .radio-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mod === mod); + }); + + // Update badge + const badge = document.getElementById('mainScannerMod'); + if (badge) { + const modLabels = { am: 'AM', fm: 'NFM', wfm: 'WFM', usb: 'USB', lsb: 'LSB' }; + badge.textContent = modLabels[mod] || mod.toUpperCase(); + } + + // Update scanner modulation select if exists + const modSelect = document.getElementById('scannerModulation'); + if (modSelect) { + modSelect.value = mod; + } + + // Sync with scanner if running + updateScannerConfig({ modulation: mod }); + + // If currently listening, restart immediately (deliberate modulation change) + if (isDirectListening) { + console.log('[MODULATION] Restarting audio with new modulation:', mod); + startDirectListenImmediate(); + } else { + console.log('[MODULATION] Not listening, just updated UI'); + } +}; + +/** + * Update sidebar quick status + */ +function updateQuickStatus() { + const quickStatus = document.getElementById('lpQuickStatus'); + const quickFreq = document.getElementById('lpQuickFreq'); + const quickSignals = document.getElementById('lpQuickSignals'); + + if (quickStatus) { + if (isScannerRunning) { + quickStatus.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING'; + quickStatus.style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)'; + } else if (isDirectListening) { + quickStatus.textContent = 'LISTENING'; + quickStatus.style.color = 'var(--accent-green)'; + } else { + quickStatus.textContent = 'IDLE'; + quickStatus.style.color = 'var(--accent-cyan)'; + } + } + + if (quickSignals) { + quickSignals.textContent = scannerSignalCount; + } +} + +// ============== SIDEBAR CONTROLS ============== + +// Frequency bookmarks stored in localStorage +let frequencyBookmarks = []; + +/** + * Load bookmarks from localStorage + */ +function loadFrequencyBookmarks() { + try { + const saved = localStorage.getItem('lpBookmarks'); + if (saved) { + frequencyBookmarks = JSON.parse(saved); + renderBookmarks(); + } + } catch (e) { + console.warn('Failed to load bookmarks:', e); + } +} + +/** + * Save bookmarks to localStorage + */ +function saveFrequencyBookmarks() { + try { + localStorage.setItem('lpBookmarks', JSON.stringify(frequencyBookmarks)); + } catch (e) { + console.warn('Failed to save bookmarks:', e); + } +} + +/** + * Add a frequency bookmark + */ +function addFrequencyBookmark() { + const input = document.getElementById('bookmarkFreqInput'); + if (!input) return; + + const freq = parseFloat(input.value); + if (isNaN(freq) || freq <= 0) { + if (typeof showNotification === 'function') { + showNotification('Invalid Frequency', 'Please enter a valid frequency'); + } + return; + } + + // Check for duplicates + if (frequencyBookmarks.some(b => Math.abs(b.freq - freq) < 0.001)) { + if (typeof showNotification === 'function') { + showNotification('Duplicate', 'This frequency is already bookmarked'); + } + return; + } + + frequencyBookmarks.push({ + freq: freq, + mod: currentModulation || 'am', + added: new Date().toISOString() + }); + + saveFrequencyBookmarks(); + renderBookmarks(); + input.value = ''; + + if (typeof showNotification === 'function') { + showNotification('Bookmark Added', `${freq.toFixed(3)} MHz saved`); + } +} + +/** + * Remove a bookmark by index + */ +function removeBookmark(index) { + frequencyBookmarks.splice(index, 1); + saveFrequencyBookmarks(); + renderBookmarks(); +} + +/** + * Render bookmarks list + */ +function renderBookmarks() { + const container = document.getElementById('bookmarksList'); + if (!container) return; + + if (frequencyBookmarks.length === 0) { + container.innerHTML = '
No bookmarks saved
'; + return; + } + + container.innerHTML = frequencyBookmarks.map((b, i) => ` +
+ ${b.freq.toFixed(3)} MHz + ${b.mod.toUpperCase()} + +
+ `).join(''); +} + + +/** + * Add a signal to the sidebar recent signals list + */ +function addSidebarRecentSignal(freq, mod) { + const container = document.getElementById('sidebarRecentSignals'); + if (!container) return; + + // Clear placeholder if present + if (container.innerHTML.includes('No signals yet')) { + container.innerHTML = ''; + } + + const timestamp = new Date().toLocaleTimeString(); + const signalDiv = document.createElement('div'); + signalDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; background: rgba(0,255,100,0.1); border-left: 2px solid var(--accent-green); margin-bottom: 2px; border-radius: 2px;'; + signalDiv.innerHTML = ` + ${freq.toFixed(3)} + ${timestamp} + `; + + container.insertBefore(signalDiv, container.firstChild); + + // Keep only last 10 signals + while (container.children.length > 10) { + container.removeChild(container.lastChild); + } +} + +// Load bookmarks on init +document.addEventListener('DOMContentLoaded', loadFrequencyBookmarks); + +// Export functions for HTML onclick handlers +window.toggleDirectListen = toggleDirectListen; +window.startDirectListen = startDirectListen; +window.stopDirectListen = stopDirectListen; +window.toggleScanner = toggleScanner; +window.startScanner = startScanner; +window.stopScanner = stopScanner; +window.pauseScanner = pauseScanner; +window.skipSignal = skipSignal; +// Note: setModulation is already exported with enhancements above +window.setBand = setBand; +window.tuneFreq = tuneFreq; +window.quickTune = quickTune; +window.addFrequencyBookmark = addFrequencyBookmark; +window.removeBookmark = removeBookmark; +window.tuneToFrequency = tuneToFrequency; +window.clearScannerLog = clearScannerLog; +window.exportScannerLog = exportScannerLog; + diff --git a/templates/index.html b/templates/index.html index ea6d380..9ba594c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,7 +3,7 @@ - INTERCEPT // See the Invisible + iNTERCEPT // See the Invisible @@ -17,13 +17,45 @@ + +
+
+ +

iNTERCEPT

+

// See the Invisible

+

Signal Intelligence & Counter Surveillance Platform

+ +

v{{ version }}

+
+
+
+ -
+