mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Timer threads now log on fire and catch all exceptions so scheduled captures never die silently. Frontend connects SSE when the scheduler is enabled (not only on manual Start) and polls /wefax/status every 10s as a fallback so the UI stays in sync with auto-fired captures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1127 lines
42 KiB
JavaScript
1127 lines
42 KiB
JavaScript
/**
|
|
* WeFax (Weather Fax) decoder module.
|
|
*
|
|
* IIFE providing start/stop controls, station selector, broadcast
|
|
* schedule timeline, live image preview, decoded image gallery,
|
|
* and audio waveform scope.
|
|
*/
|
|
var WeFax = (function () {
|
|
'use strict';
|
|
|
|
var state = {
|
|
running: false,
|
|
initialized: false,
|
|
eventSource: null,
|
|
stations: [],
|
|
images: [],
|
|
selectedStation: null,
|
|
pollTimer: null,
|
|
countdownInterval: null,
|
|
schedulerPollTimer: null,
|
|
schedulerEnabled: false,
|
|
};
|
|
|
|
// ---- Scope state ----
|
|
|
|
var scopeCtx = null;
|
|
var scopeAnim = null;
|
|
var scopeHistory = [];
|
|
var scopeWaveBuffer = [];
|
|
var scopeDisplayWave = [];
|
|
var SCOPE_HISTORY_LEN = 200;
|
|
var SCOPE_WAVE_BUFFER_LEN = 2048;
|
|
var SCOPE_WAVE_INPUT_SMOOTH = 0.55;
|
|
var SCOPE_WAVE_DISPLAY_SMOOTH = 0.22;
|
|
var SCOPE_WAVE_IDLE_DECAY = 0.96;
|
|
var scopeRms = 0;
|
|
var scopePeak = 0;
|
|
var scopeTargetRms = 0;
|
|
var scopeTargetPeak = 0;
|
|
var scopeLastWaveAt = 0;
|
|
var scopeLastInputSample = 0;
|
|
var scopeImageBurst = 0;
|
|
|
|
// ---- Initialisation ----
|
|
|
|
function init() {
|
|
if (state.initialized) {
|
|
// Re-render cached data immediately so UI isn't empty
|
|
if (state.stations.length) renderStationDropdown();
|
|
loadImages();
|
|
return;
|
|
}
|
|
state.initialized = true;
|
|
loadStations();
|
|
loadImages();
|
|
checkSchedulerStatus();
|
|
}
|
|
|
|
function destroy() {
|
|
closeImage();
|
|
disconnectSSE();
|
|
stopScope();
|
|
stopCountdownTimer();
|
|
stopSchedulerPoll();
|
|
if (state.pollTimer) {
|
|
clearInterval(state.pollTimer);
|
|
state.pollTimer = null;
|
|
}
|
|
}
|
|
|
|
// ---- Stations ----
|
|
|
|
function loadStations() {
|
|
fetch('/wefax/stations')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.status === 'ok' && data.stations) {
|
|
state.stations = data.stations;
|
|
renderStationDropdown();
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
console.error('WeFax: failed to load stations', err);
|
|
});
|
|
}
|
|
|
|
function renderStationDropdown() {
|
|
var sel = document.getElementById('wefaxStation');
|
|
if (!sel) return;
|
|
|
|
// Keep the placeholder
|
|
sel.innerHTML = '<option value="">Select a station...</option>';
|
|
|
|
state.stations.forEach(function (s) {
|
|
var opt = document.createElement('option');
|
|
opt.value = s.callsign;
|
|
opt.textContent = s.callsign + ' — ' + s.name + ' (' + s.country + ')';
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function onStationChange() {
|
|
var sel = document.getElementById('wefaxStation');
|
|
var callsign = sel ? sel.value : '';
|
|
|
|
if (!callsign) {
|
|
state.selectedStation = null;
|
|
renderFrequencyDropdown([]);
|
|
renderScheduleTimeline([]);
|
|
renderBroadcastTimeline([]);
|
|
stopCountdownTimer();
|
|
return;
|
|
}
|
|
|
|
var station = state.stations.find(function (s) { return s.callsign === callsign; });
|
|
state.selectedStation = station || null;
|
|
|
|
if (station) {
|
|
renderFrequencyDropdown(station.frequencies || []);
|
|
// Set IOC/LPM from station defaults
|
|
var iocSel = document.getElementById('wefaxIOC');
|
|
var lpmSel = document.getElementById('wefaxLPM');
|
|
if (iocSel && station.ioc) iocSel.value = String(station.ioc);
|
|
if (lpmSel && station.lpm) lpmSel.value = String(station.lpm);
|
|
renderScheduleTimeline(station.schedule || []);
|
|
renderBroadcastTimeline(station.schedule || []);
|
|
startCountdownTimer();
|
|
}
|
|
}
|
|
|
|
function renderFrequencyDropdown(frequencies) {
|
|
var sel = document.getElementById('wefaxFrequency');
|
|
if (!sel) return;
|
|
|
|
sel.innerHTML = '';
|
|
|
|
if (frequencies.length === 0) {
|
|
var opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = 'Select station first';
|
|
sel.appendChild(opt);
|
|
return;
|
|
}
|
|
|
|
frequencies.forEach(function (f) {
|
|
var opt = document.createElement('option');
|
|
opt.value = String(f.khz);
|
|
opt.textContent = f.khz + ' kHz — ' + f.description;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// ---- Start / Stop ----
|
|
|
|
function start() {
|
|
if (state.running) return;
|
|
|
|
var freqSel = document.getElementById('wefaxFrequency');
|
|
var freqKhz = freqSel ? parseFloat(freqSel.value) : 0;
|
|
if (!freqKhz || isNaN(freqKhz)) {
|
|
flashStartError();
|
|
return;
|
|
}
|
|
|
|
var stationSel = document.getElementById('wefaxStation');
|
|
var station = stationSel ? stationSel.value : '';
|
|
var iocSel = document.getElementById('wefaxIOC');
|
|
var lpmSel = document.getElementById('wefaxLPM');
|
|
var gainInput = document.getElementById('wefaxGain');
|
|
var dsCheckbox = document.getElementById('wefaxDirectSampling');
|
|
|
|
var deviceSel = document.getElementById('rtlDevice');
|
|
var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0;
|
|
|
|
var body = {
|
|
frequency_khz: freqKhz,
|
|
station: station,
|
|
device: device,
|
|
gain: gainInput ? parseFloat(gainInput.value) || 40 : 40,
|
|
ioc: iocSel ? parseInt(iocSel.value, 10) : 576,
|
|
lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120,
|
|
direct_sampling: dsCheckbox ? dsCheckbox.checked : true,
|
|
};
|
|
|
|
fetch('/wefax/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.status === 'started' || data.status === 'already_running') {
|
|
state.running = true;
|
|
updateButtons(true);
|
|
setStatus('Scanning ' + freqKhz + ' kHz...');
|
|
setStripFreq(freqKhz);
|
|
connectSSE();
|
|
} else {
|
|
var errMsg = data.message || 'unknown error';
|
|
setStatus('Error: ' + errMsg);
|
|
showStripError(errMsg);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
var errMsg = err.message || 'Network error';
|
|
setStatus('Error: ' + errMsg);
|
|
showStripError(errMsg);
|
|
});
|
|
}
|
|
|
|
function stop() {
|
|
// Immediate UI feedback before waiting for backend response
|
|
state.running = false;
|
|
updateButtons(false);
|
|
setStatus('Stopping...');
|
|
if (!state.schedulerEnabled) {
|
|
disconnectSSE();
|
|
}
|
|
|
|
fetch('/wefax/stop', { method: 'POST' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function () {
|
|
setStatus('Stopped');
|
|
loadImages();
|
|
})
|
|
.catch(function (err) {
|
|
setStatus('Stopped');
|
|
console.error('WeFax stop error:', err);
|
|
});
|
|
}
|
|
|
|
// ---- SSE ----
|
|
|
|
function connectSSE() {
|
|
disconnectSSE();
|
|
|
|
var es = new EventSource('/wefax/stream');
|
|
state.eventSource = es;
|
|
|
|
es.onmessage = function (evt) {
|
|
try {
|
|
var data = JSON.parse(evt.data);
|
|
if (data.type === 'scope') {
|
|
applyScopeData(data);
|
|
} else {
|
|
handleProgress(data);
|
|
}
|
|
} catch (e) { /* ignore keepalives */ }
|
|
};
|
|
|
|
es.onerror = function () {
|
|
// EventSource will auto-reconnect
|
|
};
|
|
|
|
// Show scope and start animation
|
|
var panel = document.getElementById('wefaxScopePanel');
|
|
if (panel) panel.style.display = 'block';
|
|
initScope();
|
|
}
|
|
|
|
function disconnectSSE() {
|
|
if (state.eventSource) {
|
|
state.eventSource.close();
|
|
state.eventSource = null;
|
|
}
|
|
stopScope();
|
|
var panel = document.getElementById('wefaxScopePanel');
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
|
|
function handleProgress(data) {
|
|
// Handle scheduler events
|
|
if (data.type === 'schedule_capture_start') {
|
|
setStatus('Auto-capture started: ' + (data.broadcast ? data.broadcast.content : ''));
|
|
state.running = true;
|
|
updateButtons(true);
|
|
connectSSE();
|
|
return;
|
|
}
|
|
if (data.type === 'schedule_capture_complete') {
|
|
setStatus('Auto-capture complete');
|
|
loadImages();
|
|
return;
|
|
}
|
|
if (data.type === 'schedule_capture_skipped') {
|
|
setStatus('Broadcast skipped: ' + (data.reason || ''));
|
|
return;
|
|
}
|
|
|
|
if (data.type !== 'wefax_progress') return;
|
|
|
|
var statusText = data.message || data.status || '';
|
|
setStatus(statusText);
|
|
|
|
var dot = document.getElementById('wefaxStripDot');
|
|
if (dot) {
|
|
dot.className = 'wefax-strip-dot ' + (data.status || 'idle');
|
|
}
|
|
|
|
var statusEl = document.getElementById('wefaxStripStatus');
|
|
if (statusEl) {
|
|
var labels = {
|
|
scanning: 'Scanning',
|
|
phasing: 'Phasing',
|
|
receiving: 'Receiving',
|
|
complete: 'Complete',
|
|
error: 'Error',
|
|
stopped: 'Idle',
|
|
};
|
|
statusEl.textContent = labels[data.status] || data.status || 'Idle';
|
|
}
|
|
|
|
// Update line count
|
|
if (data.line_count) {
|
|
var lineEl = document.getElementById('wefaxStripLines');
|
|
if (lineEl) lineEl.textContent = String(data.line_count);
|
|
}
|
|
|
|
// Live preview
|
|
if (data.partial_image) {
|
|
var previewEl = document.getElementById('wefaxLivePreview');
|
|
if (previewEl) {
|
|
previewEl.src = data.partial_image;
|
|
previewEl.style.display = 'block';
|
|
}
|
|
var idleEl = document.getElementById('wefaxIdleState');
|
|
if (idleEl) idleEl.style.display = 'none';
|
|
}
|
|
|
|
// Image complete
|
|
if (data.status === 'complete' && data.image) {
|
|
scopeImageBurst = 1.0;
|
|
loadImages();
|
|
setStatus('Image decoded: ' + (data.line_count || '?') + ' lines');
|
|
}
|
|
|
|
if (data.status === 'error') {
|
|
state.running = false;
|
|
updateButtons(false);
|
|
showStripError(data.message || 'Decode error');
|
|
}
|
|
|
|
if (data.status === 'stopped') {
|
|
state.running = false;
|
|
updateButtons(false);
|
|
}
|
|
}
|
|
|
|
// ---- Audio Waveform Scope ----
|
|
|
|
function initScope() {
|
|
var canvas = document.getElementById('wefaxScopeCanvas');
|
|
if (!canvas) return;
|
|
|
|
if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; }
|
|
|
|
resizeScopeCanvas(canvas);
|
|
scopeCtx = canvas.getContext('2d');
|
|
scopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
|
scopeWaveBuffer = [];
|
|
scopeDisplayWave = [];
|
|
scopeRms = scopePeak = scopeTargetRms = scopeTargetPeak = 0;
|
|
scopeImageBurst = scopeLastWaveAt = scopeLastInputSample = 0;
|
|
drawScope();
|
|
}
|
|
|
|
function stopScope() {
|
|
if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; }
|
|
scopeCtx = null;
|
|
scopeWaveBuffer = [];
|
|
scopeDisplayWave = [];
|
|
scopeHistory = [];
|
|
scopeLastWaveAt = 0;
|
|
scopeLastInputSample = 0;
|
|
}
|
|
|
|
function resizeScopeCanvas(canvas) {
|
|
if (!canvas) return;
|
|
var rect = canvas.getBoundingClientRect();
|
|
var dpr = window.devicePixelRatio || 1;
|
|
var width = Math.max(1, Math.floor(rect.width * dpr));
|
|
var height = Math.max(1, Math.floor(rect.height * dpr));
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
}
|
|
|
|
function applyScopeData(scopeData) {
|
|
if (!scopeData || typeof scopeData !== 'object') return;
|
|
|
|
scopeTargetRms = Number(scopeData.rms) || 0;
|
|
scopeTargetPeak = Number(scopeData.peak) || 0;
|
|
|
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
|
for (var i = 0; i < scopeData.waveform.length; i++) {
|
|
var sample = Number(scopeData.waveform[i]);
|
|
if (!isFinite(sample)) continue;
|
|
var normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
|
scopeLastInputSample += (normalized - scopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH;
|
|
scopeWaveBuffer.push(scopeLastInputSample);
|
|
}
|
|
if (scopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) {
|
|
scopeWaveBuffer.splice(0, scopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN);
|
|
}
|
|
scopeLastWaveAt = performance.now();
|
|
}
|
|
}
|
|
|
|
function drawScope() {
|
|
var ctx = scopeCtx;
|
|
if (!ctx) return;
|
|
|
|
resizeScopeCanvas(ctx.canvas);
|
|
var W = ctx.canvas.width, H = ctx.canvas.height, midY = H / 2;
|
|
|
|
// Phosphor persistence
|
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Smooth RMS/Peak
|
|
scopeRms += (scopeTargetRms - scopeRms) * 0.25;
|
|
scopePeak += (scopeTargetPeak - scopePeak) * 0.15;
|
|
|
|
// Rolling envelope
|
|
scopeHistory.push(Math.min(scopeRms / 32768, 1.0));
|
|
if (scopeHistory.length > SCOPE_HISTORY_LEN) scopeHistory.shift();
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
|
ctx.lineWidth = 0.8;
|
|
var gx, gy;
|
|
for (var i = 1; i < 8; i++) {
|
|
gx = (W / 8) * i;
|
|
ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke();
|
|
}
|
|
for (var g = 0.25; g < 1; g += 0.25) {
|
|
gy = midY - g * midY;
|
|
var gy2 = midY + g * midY;
|
|
ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
|
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2); ctx.stroke();
|
|
}
|
|
|
|
// Center baseline
|
|
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
|
|
|
|
// Amplitude envelope (amber tint)
|
|
var envStepX = W / (SCOPE_HISTORY_LEN - 1);
|
|
ctx.strokeStyle = 'rgba(255, 170, 0, 0.35)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
for (var ei = 0; ei < scopeHistory.length; ei++) {
|
|
var ex = ei * envStepX, amp = scopeHistory[ei] * midY * 0.85;
|
|
if (ei === 0) ctx.moveTo(ex, midY - amp); else ctx.lineTo(ex, midY - amp);
|
|
}
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
for (var ej = 0; ej < scopeHistory.length; ej++) {
|
|
var ex2 = ej * envStepX, amp2 = scopeHistory[ej] * midY * 0.85;
|
|
if (ej === 0) ctx.moveTo(ex2, midY + amp2); else ctx.lineTo(ex2, midY + amp2);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Waveform trace (amber)
|
|
var wavePoints = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
|
if (scopeWaveBuffer.length > 1) {
|
|
var waveIsFresh = (performance.now() - scopeLastWaveAt) < 700;
|
|
var srcLen = scopeWaveBuffer.length;
|
|
var srcWindow = Math.min(srcLen, 1536);
|
|
var srcStart = srcLen - srcWindow;
|
|
|
|
if (scopeDisplayWave.length !== wavePoints) {
|
|
scopeDisplayWave = new Array(wavePoints).fill(0);
|
|
}
|
|
|
|
for (var wi = 0; wi < wavePoints; wi++) {
|
|
var a = srcStart + Math.floor((wi / wavePoints) * srcWindow);
|
|
var b = srcStart + Math.floor(((wi + 1) / wavePoints) * srcWindow);
|
|
var start = Math.max(srcStart, Math.min(srcLen - 1, a));
|
|
var end = Math.max(start + 1, Math.min(srcLen, b));
|
|
var sum = 0, count = 0;
|
|
for (var j = start; j < end; j++) { sum += scopeWaveBuffer[j]; count++; }
|
|
var targetSample = count > 0 ? sum / count : 0;
|
|
scopeDisplayWave[wi] += (targetSample - scopeDisplayWave[wi]) * SCOPE_WAVE_DISPLAY_SMOOTH;
|
|
}
|
|
|
|
ctx.strokeStyle = waveIsFresh ? '#ffaa00' : 'rgba(255, 170, 0, 0.45)';
|
|
ctx.lineWidth = 1.7;
|
|
ctx.shadowColor = '#ffaa00';
|
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
|
|
|
var stepX = wavePoints > 1 ? W / (wavePoints - 1) : W;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, midY - scopeDisplayWave[0] * midY * 0.9);
|
|
for (var qi = 1; qi < wavePoints - 1; qi++) {
|
|
var x = qi * stepX, y = midY - scopeDisplayWave[qi] * midY * 0.9;
|
|
var nx = (qi + 1) * stepX, ny = midY - scopeDisplayWave[qi + 1] * midY * 0.9;
|
|
ctx.quadraticCurveTo(x, y, (x + nx) / 2, (y + ny) / 2);
|
|
}
|
|
ctx.lineTo((wavePoints - 1) * stepX,
|
|
midY - scopeDisplayWave[wavePoints - 1] * midY * 0.9);
|
|
ctx.stroke();
|
|
|
|
if (!waveIsFresh) {
|
|
for (var di = 0; di < scopeDisplayWave.length; di++) {
|
|
scopeDisplayWave[di] *= SCOPE_WAVE_IDLE_DECAY;
|
|
}
|
|
}
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Peak indicator
|
|
var peakNorm = Math.min(scopePeak / 32768, 1.0);
|
|
if (peakNorm > 0.01) {
|
|
var peakY = midY - peakNorm * midY * 0.9;
|
|
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// Image-decoded flash (amber overlay)
|
|
if (scopeImageBurst > 0.01) {
|
|
ctx.fillStyle = 'rgba(255, 170, 0, ' + (scopeImageBurst * 0.15) + ')';
|
|
ctx.fillRect(0, 0, W, H);
|
|
scopeImageBurst *= 0.88;
|
|
}
|
|
|
|
// Label updates
|
|
var rmsLabel = document.getElementById('wefaxScopeRmsLabel');
|
|
var peakLabel = document.getElementById('wefaxScopePeakLabel');
|
|
var statusLabel = document.getElementById('wefaxScopeStatusLabel');
|
|
if (rmsLabel) rmsLabel.textContent = Math.round(scopeRms);
|
|
if (peakLabel) peakLabel.textContent = Math.round(scopePeak);
|
|
if (statusLabel) {
|
|
var fresh = (performance.now() - scopeLastWaveAt) < 700;
|
|
if (fresh && scopeRms > 1300) {
|
|
statusLabel.textContent = 'DEMODULATING';
|
|
statusLabel.style.color = '#ffaa00';
|
|
} else if (fresh && scopeRms > 500) {
|
|
statusLabel.textContent = 'CARRIER';
|
|
statusLabel.style.color = '#cc8800';
|
|
} else if (fresh) {
|
|
statusLabel.textContent = 'QUIET';
|
|
statusLabel.style.color = '#666';
|
|
} else {
|
|
statusLabel.textContent = 'IDLE';
|
|
statusLabel.style.color = '#444';
|
|
}
|
|
}
|
|
|
|
scopeAnim = requestAnimationFrame(drawScope);
|
|
}
|
|
|
|
// ---- Images ----
|
|
|
|
function loadImages() {
|
|
fetch('/wefax/images')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.status === 'ok') {
|
|
state.images = data.images || [];
|
|
renderImageGallery();
|
|
var countEl = document.getElementById('wefaxImageCount');
|
|
if (countEl) countEl.textContent = String(state.images.length);
|
|
var stripCount = document.getElementById('wefaxStripImageCount');
|
|
if (stripCount) stripCount.textContent = String(state.images.length);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
console.error('WeFax: failed to load images', err);
|
|
});
|
|
}
|
|
|
|
function renderImageGallery() {
|
|
var gallery = document.getElementById('wefaxGallery');
|
|
if (!gallery) return;
|
|
|
|
if (state.images.length === 0) {
|
|
gallery.innerHTML = '<div class="wefax-gallery-empty">No images decoded yet</div>';
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
// Show newest first
|
|
var sorted = state.images.slice().reverse();
|
|
sorted.forEach(function (img) {
|
|
var ts = img.timestamp ? new Date(img.timestamp).toLocaleString() : '';
|
|
var station = img.station || '';
|
|
var freq = img.frequency_khz ? (img.frequency_khz + ' kHz') : '';
|
|
html += '<div class="wefax-gallery-item">';
|
|
html += '<img src="' + img.url + '" alt="WeFax" loading="lazy" onclick="WeFax.viewImage(\'' + img.url + '\', \'' + img.filename + '\')">';
|
|
html += '<div class="wefax-gallery-meta">';
|
|
html += '<span>' + station + (freq ? ' ' + freq : '') + '</span>';
|
|
html += '<span>' + ts + '</span>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
});
|
|
gallery.innerHTML = html;
|
|
}
|
|
|
|
function deleteImage(filename) {
|
|
if (!confirm('Delete this image?')) return;
|
|
fetch('/wefax/images/' + encodeURIComponent(filename), { method: 'DELETE' })
|
|
.then(function () {
|
|
closeImage();
|
|
loadImages();
|
|
})
|
|
.catch(function (err) { console.error('WeFax delete error:', err); });
|
|
}
|
|
|
|
function deleteAllImages() {
|
|
if (!confirm('Delete all WeFax images?')) return;
|
|
fetch('/wefax/images', { method: 'DELETE' })
|
|
.then(function () { loadImages(); })
|
|
.catch(function (err) { console.error('WeFax delete all error:', err); });
|
|
}
|
|
|
|
var currentModalUrl = null;
|
|
var currentModalFilename = null;
|
|
|
|
function viewImage(url, filename) {
|
|
currentModalUrl = url;
|
|
currentModalFilename = filename || null;
|
|
|
|
var modal = document.getElementById('wefaxImageModal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'wefaxImageModal';
|
|
modal.className = 'wefax-image-modal';
|
|
modal.innerHTML =
|
|
'<div class="wefax-modal-toolbar">' +
|
|
'<button class="wefax-modal-btn" id="wefaxModalDownload" title="Download">' +
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' +
|
|
' Download' +
|
|
'</button>' +
|
|
'<button class="wefax-modal-btn delete" id="wefaxModalDelete" title="Delete">' +
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>' +
|
|
' Delete' +
|
|
'</button>' +
|
|
'</div>' +
|
|
'<button class="wefax-modal-close" onclick="WeFax.closeImage()">×</button>' +
|
|
'<img src="" alt="WeFax Image">';
|
|
modal.addEventListener('click', function (e) {
|
|
if (e.target === modal) closeImage();
|
|
});
|
|
modal.querySelector('#wefaxModalDownload').addEventListener('click', function () {
|
|
if (currentModalUrl && currentModalFilename) {
|
|
var a = document.createElement('a');
|
|
a.href = currentModalUrl;
|
|
a.download = currentModalFilename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
});
|
|
modal.querySelector('#wefaxModalDelete').addEventListener('click', function () {
|
|
if (currentModalFilename) {
|
|
deleteImage(currentModalFilename);
|
|
}
|
|
});
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
modal.querySelector('img').src = url;
|
|
modal.classList.add('show');
|
|
}
|
|
|
|
function closeImage() {
|
|
var modal = document.getElementById('wefaxImageModal');
|
|
if (modal) modal.classList.remove('show');
|
|
}
|
|
|
|
// ---- Schedule Timeline ----
|
|
|
|
function renderScheduleTimeline(schedule) {
|
|
var container = document.getElementById('wefaxScheduleTimeline');
|
|
if (!container) return;
|
|
|
|
if (!schedule || schedule.length === 0) {
|
|
container.innerHTML = '<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>';
|
|
return;
|
|
}
|
|
|
|
var now = new Date();
|
|
var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
|
|
var html = '<div class="wefax-schedule-list">';
|
|
schedule.forEach(function (entry) {
|
|
var parts = entry.utc.split(':');
|
|
var entryMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
|
|
var diff = entryMin - nowMin;
|
|
if (diff < -720) diff += 1440;
|
|
if (diff > 720) diff -= 1440;
|
|
|
|
var cls = 'wefax-schedule-entry';
|
|
var badge = '';
|
|
if (diff >= 0 && diff <= entry.duration_min) {
|
|
cls += ' active';
|
|
badge = '<span class="wefax-schedule-badge live">LIVE</span>';
|
|
} else if (diff > 0 && diff <= 60) {
|
|
cls += ' upcoming';
|
|
badge = '<span class="wefax-schedule-badge soon">' + diff + 'm</span>';
|
|
} else if (diff > 0) {
|
|
badge = '<span class="wefax-schedule-badge">' + Math.floor(diff / 60) + 'h ' + (diff % 60) + 'm</span>';
|
|
} else {
|
|
cls += ' past';
|
|
}
|
|
|
|
html += '<div class="' + cls + '">';
|
|
html += '<span class="wefax-schedule-time">' + entry.utc + '</span>';
|
|
html += '<span class="wefax-schedule-content">' + entry.content + '</span>';
|
|
html += badge;
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ---- UI helpers ----
|
|
|
|
function updateButtons(running) {
|
|
var startBtn = document.getElementById('wefaxStartBtn');
|
|
var stopBtn = document.getElementById('wefaxStopBtn');
|
|
if (startBtn) startBtn.style.display = running ? 'none' : 'inline-flex';
|
|
if (stopBtn) stopBtn.style.display = running ? 'inline-flex' : 'none';
|
|
|
|
var dot = document.getElementById('wefaxStripDot');
|
|
if (dot) dot.className = 'wefax-strip-dot ' + (running ? 'scanning' : 'idle');
|
|
|
|
var statusEl = document.getElementById('wefaxStripStatus');
|
|
if (statusEl && !running) statusEl.textContent = 'Idle';
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
var el = document.getElementById('wefaxStatusText');
|
|
if (el) el.textContent = msg;
|
|
}
|
|
|
|
function setStripFreq(khz) {
|
|
var el = document.getElementById('wefaxStripFreq');
|
|
if (el) el.textContent = String(khz);
|
|
}
|
|
|
|
function showStripError(msg) {
|
|
var statusEl = document.getElementById('wefaxStripStatus');
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Error: ' + msg;
|
|
statusEl.style.color = '#ff4444';
|
|
}
|
|
var dot = document.getElementById('wefaxStripDot');
|
|
if (dot) dot.className = 'wefax-strip-dot error';
|
|
}
|
|
|
|
function flashStartError() {
|
|
setStatus('Select a station and frequency first');
|
|
|
|
// Flash the Start button itself (most visible feedback)
|
|
var startBtn = document.getElementById('wefaxStartBtn');
|
|
if (startBtn) {
|
|
startBtn.classList.add('wefax-strip-btn-error');
|
|
setTimeout(function () {
|
|
startBtn.classList.remove('wefax-strip-btn-error');
|
|
}, 2500);
|
|
}
|
|
|
|
// Show error in strip status text (right next to the button)
|
|
var stripStatus = document.getElementById('wefaxStripStatus');
|
|
if (stripStatus) {
|
|
var prevText = stripStatus.textContent;
|
|
stripStatus.textContent = 'Select Station';
|
|
stripStatus.style.color = '#ffaa00';
|
|
setTimeout(function () {
|
|
stripStatus.textContent = prevText || 'Idle';
|
|
stripStatus.style.color = '';
|
|
}, 2500);
|
|
}
|
|
|
|
// Also update the schedule panel status
|
|
var statusEl = document.getElementById('wefaxStatusText');
|
|
if (statusEl) {
|
|
statusEl.style.color = '#ffaa00';
|
|
statusEl.style.fontWeight = '600';
|
|
setTimeout(function () {
|
|
statusEl.style.color = '';
|
|
statusEl.style.fontWeight = '';
|
|
}, 2500);
|
|
}
|
|
|
|
// Flash station/frequency dropdowns
|
|
var stationSel = document.getElementById('wefaxStation');
|
|
var freqSel = document.getElementById('wefaxFrequency');
|
|
[stationSel, freqSel].forEach(function (el) {
|
|
if (!el) return;
|
|
el.style.borderColor = '#ffaa00';
|
|
el.style.boxShadow = '0 0 4px #ffaa0066';
|
|
setTimeout(function () {
|
|
el.style.borderColor = '';
|
|
el.style.boxShadow = '';
|
|
}, 2500);
|
|
});
|
|
}
|
|
|
|
// ---- Broadcast Timeline + Countdown ----
|
|
|
|
function renderBroadcastTimeline(schedule) {
|
|
var bar = document.getElementById('wefaxCountdownBar');
|
|
var track = document.getElementById('wefaxTimelineTrack');
|
|
if (!bar || !track) return;
|
|
|
|
if (!schedule || schedule.length === 0) {
|
|
bar.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
bar.style.display = 'flex';
|
|
|
|
// Clear existing broadcast markers
|
|
var existing = track.querySelectorAll('.wefax-timeline-broadcast');
|
|
for (var i = 0; i < existing.length; i++) {
|
|
existing[i].parentNode.removeChild(existing[i]);
|
|
}
|
|
|
|
var now = new Date();
|
|
var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
|
|
|
|
schedule.forEach(function (entry) {
|
|
var parts = entry.utc.split(':');
|
|
var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
|
|
var duration = entry.duration_min || 20;
|
|
var leftPct = (startMin / 1440) * 100;
|
|
var widthPct = (duration / 1440) * 100;
|
|
|
|
var block = document.createElement('div');
|
|
block.className = 'wefax-timeline-broadcast';
|
|
block.title = entry.utc + ' — ' + entry.content;
|
|
|
|
// Mark active broadcasts
|
|
var diff = nowMin - startMin;
|
|
if (diff >= 0 && diff < duration) {
|
|
block.classList.add('active');
|
|
}
|
|
|
|
block.style.left = leftPct + '%';
|
|
block.style.width = Math.max(widthPct, 0.3) + '%';
|
|
track.appendChild(block);
|
|
});
|
|
|
|
updateTimelineCursor();
|
|
}
|
|
|
|
function updateTimelineCursor() {
|
|
var cursor = document.getElementById('wefaxTimelineCursor');
|
|
if (!cursor) return;
|
|
|
|
var now = new Date();
|
|
var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60;
|
|
cursor.style.left = ((nowMin / 1440) * 100) + '%';
|
|
}
|
|
|
|
function startCountdownTimer() {
|
|
stopCountdownTimer();
|
|
updateCountdown();
|
|
state.countdownInterval = setInterval(function () {
|
|
updateCountdown();
|
|
updateTimelineCursor();
|
|
}, 1000);
|
|
}
|
|
|
|
function updateCountdown() {
|
|
var station = state.selectedStation;
|
|
if (!station || !station.schedule || !station.schedule.length) return;
|
|
|
|
var now = new Date();
|
|
var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60;
|
|
|
|
// Find next upcoming or currently active broadcast
|
|
var bestDiff = Infinity;
|
|
var bestEntry = null;
|
|
var isActive = false;
|
|
|
|
station.schedule.forEach(function (entry) {
|
|
var parts = entry.utc.split(':');
|
|
var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
|
|
var duration = entry.duration_min || 20;
|
|
|
|
// Check if currently active
|
|
var elapsed = nowMin - startMin;
|
|
if (elapsed < 0) elapsed += 1440;
|
|
if (elapsed >= 0 && elapsed < duration) {
|
|
bestEntry = entry;
|
|
bestDiff = 0;
|
|
isActive = true;
|
|
return;
|
|
}
|
|
|
|
// Time until start
|
|
var diff = startMin - nowMin;
|
|
if (diff < 0) diff += 1440;
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestEntry = entry;
|
|
}
|
|
});
|
|
|
|
if (!bestEntry) return;
|
|
|
|
var hoursEl = document.getElementById('wefaxCdHours');
|
|
var minsEl = document.getElementById('wefaxCdMins');
|
|
var secsEl = document.getElementById('wefaxCdSecs');
|
|
var contentEl = document.getElementById('wefaxCountdownContent');
|
|
var detailEl = document.getElementById('wefaxCountdownDetail');
|
|
var boxes = document.getElementById('wefaxCountdownBoxes');
|
|
|
|
if (isActive) {
|
|
// Show "LIVE" countdown
|
|
var parts = bestEntry.utc.split(':');
|
|
var startMin2 = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
|
|
var duration2 = bestEntry.duration_min || 20;
|
|
var elapsed2 = nowMin - startMin2;
|
|
if (elapsed2 < 0) elapsed2 += 1440;
|
|
var remaining = duration2 - elapsed2;
|
|
var remTotalSec = Math.max(0, Math.floor(remaining * 60));
|
|
var h = Math.floor(remTotalSec / 3600);
|
|
var m = Math.floor((remTotalSec % 3600) / 60);
|
|
var s = remTotalSec % 60;
|
|
|
|
if (hoursEl) hoursEl.textContent = String(h).padStart(2, '0');
|
|
if (minsEl) minsEl.textContent = String(m).padStart(2, '0');
|
|
if (secsEl) secsEl.textContent = String(s).padStart(2, '0');
|
|
if (contentEl) contentEl.textContent = bestEntry.content;
|
|
if (detailEl) detailEl.textContent = 'LIVE — ' + bestEntry.utc + ' UTC';
|
|
|
|
// Set active class on boxes
|
|
if (boxes) {
|
|
var boxEls = boxes.querySelectorAll('.wefax-countdown-box');
|
|
for (var i = 0; i < boxEls.length; i++) {
|
|
boxEls[i].classList.remove('imminent');
|
|
boxEls[i].classList.add('active');
|
|
}
|
|
}
|
|
} else {
|
|
// Countdown to next
|
|
var totalSec = Math.max(0, Math.floor(bestDiff * 60));
|
|
var h2 = Math.floor(totalSec / 3600);
|
|
var m2 = Math.floor((totalSec % 3600) / 60);
|
|
var s2 = totalSec % 60;
|
|
|
|
if (hoursEl) hoursEl.textContent = String(h2).padStart(2, '0');
|
|
if (minsEl) minsEl.textContent = String(m2).padStart(2, '0');
|
|
if (secsEl) secsEl.textContent = String(s2).padStart(2, '0');
|
|
if (contentEl) contentEl.textContent = bestEntry.content;
|
|
if (detailEl) detailEl.textContent = 'Next at ' + bestEntry.utc + ' UTC';
|
|
|
|
// Set imminent class when < 10 min
|
|
if (boxes) {
|
|
var boxEls2 = boxes.querySelectorAll('.wefax-countdown-box');
|
|
var isImminent = bestDiff < 10;
|
|
for (var j = 0; j < boxEls2.length; j++) {
|
|
boxEls2[j].classList.remove('active');
|
|
if (isImminent) {
|
|
boxEls2[j].classList.add('imminent');
|
|
} else {
|
|
boxEls2[j].classList.remove('imminent');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopCountdownTimer() {
|
|
if (state.countdownInterval) {
|
|
clearInterval(state.countdownInterval);
|
|
state.countdownInterval = null;
|
|
}
|
|
}
|
|
|
|
// ---- Auto-Capture Scheduler ----
|
|
|
|
function checkSchedulerStatus() {
|
|
fetch('/wefax/schedule/status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var strip = document.getElementById('wefaxStripAutoSchedule');
|
|
var sidebar = document.getElementById('wefaxSidebarAutoSchedule');
|
|
if (strip) strip.checked = !!data.enabled;
|
|
if (sidebar) sidebar.checked = !!data.enabled;
|
|
state.schedulerEnabled = !!data.enabled;
|
|
if (data.enabled) {
|
|
connectSSE();
|
|
startSchedulerPoll();
|
|
}
|
|
})
|
|
.catch(function () { /* ignore */ });
|
|
}
|
|
|
|
function enableScheduler() {
|
|
var stationSel = document.getElementById('wefaxStation');
|
|
var station = stationSel ? stationSel.value : '';
|
|
var freqSel = document.getElementById('wefaxFrequency');
|
|
var freqKhz = freqSel ? parseFloat(freqSel.value) : 0;
|
|
|
|
if (!station || !freqKhz || isNaN(freqKhz)) {
|
|
flashStartError();
|
|
syncSchedulerCheckboxes(false);
|
|
return;
|
|
}
|
|
|
|
var deviceSel = document.getElementById('rtlDevice');
|
|
var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0;
|
|
var gainInput = document.getElementById('wefaxGain');
|
|
var iocSel = document.getElementById('wefaxIOC');
|
|
var lpmSel = document.getElementById('wefaxLPM');
|
|
var dsCheckbox = document.getElementById('wefaxDirectSampling');
|
|
|
|
fetch('/wefax/schedule/enable', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
station: station,
|
|
frequency_khz: freqKhz,
|
|
device: device,
|
|
gain: gainInput ? parseFloat(gainInput.value) || 40 : 40,
|
|
ioc: iocSel ? parseInt(iocSel.value, 10) : 576,
|
|
lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120,
|
|
direct_sampling: dsCheckbox ? dsCheckbox.checked : true,
|
|
}),
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.status === 'ok') {
|
|
setStatus('Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled');
|
|
syncSchedulerCheckboxes(true);
|
|
state.schedulerEnabled = true;
|
|
connectSSE();
|
|
startSchedulerPoll();
|
|
} else {
|
|
setStatus('Scheduler error: ' + (data.message || 'unknown'));
|
|
syncSchedulerCheckboxes(false);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setStatus('Scheduler error: ' + err.message);
|
|
syncSchedulerCheckboxes(false);
|
|
});
|
|
}
|
|
|
|
function disableScheduler() {
|
|
fetch('/wefax/schedule/disable', { method: 'POST' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function () {
|
|
setStatus('Auto-capture disabled');
|
|
syncSchedulerCheckboxes(false);
|
|
state.schedulerEnabled = false;
|
|
stopSchedulerPoll();
|
|
if (!state.running) {
|
|
disconnectSSE();
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
console.error('WeFax scheduler disable error:', err);
|
|
});
|
|
}
|
|
|
|
function toggleScheduler(checkbox) {
|
|
if (checkbox.checked) {
|
|
enableScheduler();
|
|
} else {
|
|
disableScheduler();
|
|
}
|
|
}
|
|
|
|
function startSchedulerPoll() {
|
|
stopSchedulerPoll();
|
|
state.schedulerPollTimer = setInterval(function () {
|
|
fetch('/wefax/status')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.running && !state.running) {
|
|
state.running = true;
|
|
updateButtons(true);
|
|
setStatus('Auto-capture in progress...');
|
|
connectSSE();
|
|
} else if (!data.running && state.running) {
|
|
state.running = false;
|
|
updateButtons(false);
|
|
loadImages();
|
|
}
|
|
})
|
|
.catch(function () { /* ignore poll errors */ });
|
|
}, 10000);
|
|
}
|
|
|
|
function stopSchedulerPoll() {
|
|
if (state.schedulerPollTimer) {
|
|
clearInterval(state.schedulerPollTimer);
|
|
state.schedulerPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function syncSchedulerCheckboxes(enabled) {
|
|
var strip = document.getElementById('wefaxStripAutoSchedule');
|
|
var sidebar = document.getElementById('wefaxSidebarAutoSchedule');
|
|
if (strip) strip.checked = enabled;
|
|
if (sidebar) sidebar.checked = enabled;
|
|
}
|
|
|
|
// ---- Public API ----
|
|
|
|
return {
|
|
init: init,
|
|
destroy: destroy,
|
|
start: start,
|
|
stop: stop,
|
|
onStationChange: onStationChange,
|
|
loadImages: loadImages,
|
|
deleteImage: deleteImage,
|
|
deleteAllImages: deleteAllImages,
|
|
viewImage: viewImage,
|
|
closeImage: closeImage,
|
|
toggleScheduler: toggleScheduler,
|
|
};
|
|
})();
|