Files
fm-dx-webserver/web/js/enchanced_tuning.js
T

4257 lines
210 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Enhanced Tuning V3.1.1 Analog Scale Integration, AM Scanner, Full Admin UI
// -------------------------------------------------------------------------------------
/* global document, socket, window, WebSocket, Event, addIconToPluginPanel */
(() => {
let pluginConfig = {
LAYOUT_STYLE: 'modern',
HIDE_ALL_BUTTONS: false,
SHOW_LOOP_BUTTON: true,
SHOW_BAND_RANGE: true,
HIDE_DECIMAL_FOR_HF: false,
HIDE_DECIMAL_HF_THRESHOLD: 30,
ENABLE_TUNE_STEP_FEATURE: true,
TUNE_STEP_TIMEOUT_SECONDS: 20,
ENABLED_BANDS: ['FM', 'OIRT', 'SW', 'MW', 'LW'],
TUNING_STANDARD: 'international',
ENABLE_MW_STEP_TOGGLE: true,
ENABLE_FREQUENCY_MEMORY: true,
ENABLE_SMART_KHZ_INPUT: true,
ENABLE_AM_BW: true,
FIRMWARE_TYPE: 'FM-DX-Tuner',
ENABLE_DEFAULT_AM_BW: false,
DEFAULT_AM_BW_VALUE: '56000',
overrideServerTuningLimit: true,
fmLower: 64.0, fmUpper: 108.0,
amLower: 0.144, amUpper: 30.0,
ENABLE_AM_SCANNER: false,
ENABLE_ANALOG_SCALE: true,
ANALOG_SCALE_AUTOSTART: false,
SHOW_TUNING_KNOB: true,
ANALOG_SCALE_BRIGHTNESS: 1.5,
ENABLE_VU_METER: true,
VU_METER_GAIN_FM: 1.5,
VU_METER_GAIN_AM: 1.5,
VU_METER_MODE: 'RMS',
ENABLE_MAGIC_EYE: true,
ENABLE_PS_SCALE: true,
ENABLE_SPECTRUM_OVERLAY: true,
ENABLE_SW_STATIONS_SCALE: false,
customMainBands: {
'AM_SUPER': { name: 'AM', tune: 1.000, start: 0.144, end: 27.0 },
'FM': { name: 'FM', tune: 87.500, start: 87.5, end: 108.0 },
'OIRT': { name: 'OIRT', tune: 65.900, start: 65.9, end: 74.0 },
'SW': { name: 'SW', tune: 9.400, start: 1.710, end: 27.0 },
'MW': { name: 'MW', tune: 0.504, start: 0.504, end: 1.701 },
'LW': { name: 'LW', tune: 0.144, start: 0.144, end: 0.351 }
},
customSwBands: {
'160m': { tune: 1.8, start: 1.8, end: 2.0 }, '120m': { tune: 2.3, start: 2.3, end: 2.5 },
'90m': { tune: 3.2, start: 3.2, end: 3.4 }, '75m': { tune: 3.9, start: 3.9, end: 4.0 },
'60m': { tune: 4.75, start: 4.75, end: 5.06 }, '49m': { tune: 5.9, start: 5.9, end: 6.2 },
'41m': { tune: 7.2, start: 7.2, end: 7.6 }, '31m': { tune: 9.4, start: 9.4, end: 9.9 },
'25m': { tune: 11.6, start: 11.6, end: 12.1 }, '22m': { tune: 13.57, start: 13.57, end: 13.87 },
'19m': { tune: 15.1, start: 15.1, end: 15.83 }, '16m': { tune: 17.48, start: 17.48, end: 17.9 },
'15m': { tune: 18.9, start: 18.9, end: 19.02 }, '13m': { tune: 21.45, start: 21.45, end: 21.85 },
'11m': { tune: 25.67, start: 25.67, end: 26.1 }
}
};
let isAdmin = false;
let isLocalScannerRunning = false;
let isInternalScannerTuning = false;
let localScannerTimer = null;
let localScannerMode = null;
let localScannerDir = 'up';
let _startLocalScanner = () => {};
let _stopLocalScanner = () => {};
const getGlobalCurrentFrequencyInMHz = () => {
const el = document.getElementById('data-frequency');
if (!el) return 0;
const freqText = el.textContent;
let freqValue = parseFloat(freqText);
if (freqText.toLowerCase().includes('khz')) freqValue /= 1000;
return freqValue;
};
const safeTuneToFrequency = (frequencyInMHz) => {
if (typeof socket !== 'undefined' && socket && socket.readyState === WebSocket.OPEN) {
if (isLocalScannerRunning) isInternalScannerTuning = true;
socket.send("T" + Math.round(parseFloat(frequencyInMHz) * 1000));
isInternalScannerTuning = false;
}
};
const originalFillRect = CanvasRenderingContext2D.prototype.fillRect;
CanvasRenderingContext2D.prototype.fillRect = function(x, y, w, h) {
if (this.canvas && this.canvas.id === 'sdr-graph' && AnalogScaleEngine.isVisible) {
if (y === 9) return;
}
originalFillRect.call(this, x, y, w, h);
};
// =========================================================================
// DYNAMIC ANALOG SCALE ENGINE
// =========================================================================
const AnalogScaleEngine = {
isVisible: false,
isFading: false,
wrap: null,
vuCanvas: null,
scaleCanvas: null,
knobCanvas: null,
rafId: null,
lastRenderTime: 0,
lastKnobState: "",
outerVelocity: 0,
innerVelocity: 0,
FRICTION: 0.90,
lastDragTime: 0,
animFreq: null,
currentFreq: null,
dragFreq: null,
lastTuneTime: 0,
finalTuneTimer: null,
SMOOTHING: 0.08,
stationScrollY: 0,
targetStationScrollY: 0,
clearHovered: false,
clearClickedTime: 0,
isDraggingScale: false,
isDraggingOuterKnob: false,
isDraggingInnerKnob: false,
lastDragAngle: 0,
accumulatedOuterAngle: 0,
outerKnobAngle: 0,
innerKnobAngle: 0,
isFineTuningMode: false,
knobDragMoved: false,
mX: 0, mY: 0, mW: 0, mH: 0,
knobX: 0, knobY: 0, knobOuterR: 0, knobInnerR: 0,
currentMin: 87.5,
currentMax: 108.0,
currentUnit: 'MHz',
currentName: 'FM',
currentKey: 'FM',
_colorCache: {},
_btnAdding: false,
_hasAutostarted: false,
_resizeObserver: null,
renderedStations:[],
swStations:[],
lastFetchedSwBand: null,
hoveredFreq: null,
scanHovered: false,
scanClickedTime: 0,
getBrightness() {
return parseFloat(localStorage.getItem('et_user_scale_brightness')) || pluginConfig.ANALOG_SCALE_BRIGHTNESS;
},
isVuEnabled() {
return pluginConfig.ENABLE_VU_METER && localStorage.getItem('et_user_vu_enabled') !== 'false';
},
isPsEnabled() {
return pluginConfig.ENABLE_PS_SCALE && localStorage.getItem('et_user_ps_scale') !== 'false';
},
isSpectrumEnabled() {
return pluginConfig.ENABLE_SPECTRUM_OVERLAY && localStorage.getItem('et_user_spectrum_overlay') !== 'false';
},
isSwScaleEnabled() {
return pluginConfig.ENABLE_SW_STATIONS_SCALE && localStorage.getItem('et_user_sw_scale') !== 'false';
},
async getServerLocation() {
// Hvis vi allerede har hentet dem, bruk dem direkte
if (this.serverLat !== undefined && this.serverLon !== undefined) {
return { lat: this.serverLat, lon: this.serverLon };
}
try {
// Henter statiske serverdata fra FM-DX
const res = await fetch('/static_data');
if (res.ok) {
const data = await res.json();
// FIX: Bruker qthLatitude/qthLongitude fra din JSON (med lat/lon som fallback)
let apiLat = data.qthLatitude || data.lat;
let apiLon = data.qthLongitude || data.lon;
if (apiLat !== undefined && apiLon !== undefined && apiLat !== 0 && apiLon !== 0) {
this.serverLat = parseFloat(apiLat);
this.serverLon = parseFloat(apiLon);
// Lagre i localStorage
localStorage.setItem('rx_lat', this.serverLat);
localStorage.setItem('rx_lon', this.serverLon);
return { lat: this.serverLat, lon: this.serverLon };
}
}
} catch(e) {
console.warn("[Enhanced Tuning] Kunne ikke hente koordinater fra /static_data", e);
}
// Fallback
let lat = localStorage.getItem('rx_lat');
let lon = localStorage.getItem('rx_lon');
if (lat && lon && lat !== '0' && lon !== '0') {
this.serverLat = parseFloat(lat);
this.serverLon = parseFloat(lon);
return { lat: this.serverLat, lon: this.serverLon };
}
return { lat: 0, lon: 0 };
},
async fetchSwStations(startMhz, endMhz) {
try {
// Hent de ekte koordinatene fra den nye funksjonen vår
const coords = await this.getServerLocation();
const fStart = Math.round(startMhz * 1000);
const fEnd = Math.round(endMhz * 1000);
// Sender de nøyaktige server-koordinatene til AM Station Info API-et
const res = await fetch(`/aoki-api-proxy?lat=${coords.lat}&lon=${coords.lon}&freqStart=${fStart}&freqEnd=${fEnd}`);
if (!res.ok) return;
const data = await res.json();
if (data && data.status === 'success') {
this.swStations = data.stations ||[];
if (this.isVisible && this.scaleCanvas) {
this.drawScale(this.scaleCanvas, this.animFreq !== null ? this.animFreq : this.currentFreq);
}
}
} catch (e) {
console.error("[Enhanced Tuning] Error fetching SW stations:", e);
}
},
async fetchMwLwFavorites() {
try {
const res = await fetch('/AM-Station_info/favorites');
if (res.ok) {
this.mwLwFavorites = await res.json();
if (this.isVisible && this.scaleCanvas) {
this.drawScale(this.scaleCanvas, this.animFreq !== null ? this.animFreq : this.currentFreq);
}
}
} catch (e) {
console.warn("[Enhanced Tuning] Error fetching MW/LW favorites:", e);
}
},
applyScaleLayout() {
if (!this.wrap) return;
const scaleDiv = document.getElementById("et-analog-scale-container");
const knobDiv = document.getElementById("et-analog-knob-container");
const vuDiv = document.getElementById("et-analog-vu-container");
if (!scaleDiv || !knobDiv || !vuDiv) return;
const showKnob = pluginConfig.SHOW_TUNING_KNOB !== false;
const showVu = this.isVuEnabled();
knobDiv.style.display = showKnob ? "block" : "none";
vuDiv.style.display = showVu ? "block" : "none";
if (showKnob && showVu) {
scaleDiv.style.flex = "0 0 58%";
knobDiv.style.flex = "0 0 12%";
vuDiv.style.flex = "0 0 30%";
vuDiv.style.marginLeft = "0px";
} else if (showKnob && !showVu) {
scaleDiv.style.flex = "0 0 85%";
knobDiv.style.flex = "0 0 15%";
vuDiv.style.marginLeft = "0px";
} else if (!showKnob && showVu) {
scaleDiv.style.flex = "0 0 70%";
vuDiv.style.flex = "0 0 30%";
vuDiv.style.marginLeft = "0px";
} else {
scaleDiv.style.flex = "0 0 100%";
}
this.resizeCanvas();
this.lastKnobState = "";
},
audioInitialized: false,
audioCtx: null,
analyserL: null, analyserR: null,
dataL: null, dataR: null,
currentVuLeft: 0, currentVuRight: 0,
init() {
if (!pluginConfig.ENABLE_ANALOG_SCALE) return;
this.hookPS();
this.setupRetroTooltip();
const themeObserver = new MutationObserver((mutations) => {
let themeChanged = false;
for (const mutation of mutations) {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'data-theme')) {
themeChanged = true;
break;
}
}
if (themeChanged) {
this.lastKnobState = "";
console.log("[Enhanced Tuning] Tema-endring oppdaget! Oppdaterer hjulet...");
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
themeObserver.observe(document.body, { attributes: true, attributeFilter:['class', 'style', 'data-theme'] });
},
tryInitAudio() {
if (!this.isVuEnabled()) return;
if (this.audioInitialized && this.audioCtx) {
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume().catch(() => {});
}
if (typeof Stream !== 'undefined' && Stream && Stream.Fallback && Stream.Fallback.Player && Stream.Fallback.Player.Amplification) {
if (this.audioCtx !== Stream.Fallback.Player.Amplification.context) {
this.audioInitialized = false;
}
}
if (this.audioInitialized) return;
}
if (typeof Stream !== 'undefined' && Stream && Stream.Fallback && Stream.Fallback.Player && Stream.Fallback.Player.Amplification) {
try {
let source = Stream.Fallback.Player.Amplification;
if (!source.context) return;
this.audioCtx = source.context;
let splitter = this.audioCtx.createChannelSplitter(2);
this.analyserL = this.audioCtx.createAnalyser();
this.analyserR = this.audioCtx.createAnalyser();
this.analyserL.fftSize = 512;
this.analyserR.fftSize = 512;
this.dataL = new Float32Array(this.analyserL.fftSize);
this.dataR = new Float32Array(this.analyserR.fftSize);
try { source.connect(splitter); } catch(e){}
try { splitter.connect(this.analyserL, 0); } catch(e){}
try { splitter.connect(this.analyserR, 1); } catch(e){}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume().catch(() => {});
}
this.audioInitialized = true;
} catch(e) {}
}
},
getLevel(floatArray) {
if (!floatArray || floatArray.length === 0) return 0;
const isAmBand = (this.currentFreq !== null && this.currentFreq < 30.0);
const gain = isAmBand ? (pluginConfig.VU_METER_GAIN_AM || 2.5) : (pluginConfig.VU_METER_GAIN_FM || 1.5);
let level = 0;
if (pluginConfig.VU_METER_MODE === 'Peak') {
let max = 0;
for (let i = 0; i < floatArray.length; i++) {
let abs = Math.abs(floatArray[i]);
if (abs > max) max = abs;
}
if (max < 0.01) return 0;
level = max * (2.0 * gain);
} else {
let sumSquares = 0;
for (let i = 0; i < floatArray.length; i++) {
sumSquares += floatArray[i] * floatArray[i];
}
let rms = Math.sqrt(sumSquares / floatArray.length);
if (rms < 0.002) return 0;
level = rms * (3.5 * gain);
if (level > 0.05) {
level += (Math.random() - 0.5) * 0.025;
}
}
return Math.min(1.0, level);
},
updateBand(bandData, freq, bandKey) {
if (!bandData || !pluginConfig.ENABLE_ANALOG_SCALE) return;
if (this.currentFreq === null) {
this.currentFreq = freq;
this.animFreq = freq;
this.dragFreq = freq;
} else {
if (!this.isDraggingScale && !this.isDraggingOuterKnob && !this.isDraggingInnerKnob && Math.abs(this.outerVelocity) < 0.001 && Math.abs(this.innerVelocity) < 0.001) {
if (this.currentFreq !== freq) {
let diff = freq - this.currentFreq;
if (this.currentKey === (bandKey || 'FM') || Math.abs(diff) < 3.0) {
let step = this.getStep();
if (this.isFineTuningMode) {
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const fineStep = isFmSpectrum ? 0.010 : 0.001;
this.innerKnobAngle += (diff / fineStep) * (Math.PI / 6);
} else {
this.outerKnobAngle += (diff / step) * (Math.PI / 20);
}
}
}
this.currentFreq = freq;
}
}
this.currentKey = bandKey || 'FM';
this.addButton();
const isSwSubband = this.currentKey && (this.currentKey in pluginConfig.customSwBands || this.currentKey.endsWith('m'));
const isMwLw = this.currentKey === 'MW' || this.currentKey === 'LW';
if (isSwSubband && this.isSwScaleEnabled()) {
if (this.lastFetchedSwBand !== this.currentKey) {
this.fetchSwStations(bandData.start, bandData.end);
this.lastFetchedSwBand = this.currentKey;
}
} else if (isMwLw && this.isSwScaleEnabled()) {
// FIX: Sjekk at vi ikke allerede har lastet dem!
if (this.lastFetchedSwBand !== this.currentKey) {
this.fetchMwLwFavorites();
this.lastFetchedSwBand = this.currentKey;
}
} else {
this.swStations = [];
this.mwLwFavorites =[];
this.lastFetchedSwBand = null;
}
if (this.currentMin !== bandData.start || this.currentMax !== bandData.end || this.currentKey !== bandKey) {
this.currentMin = bandData.start;
this.currentMax = bandData.end;
this.currentUnit = bandData.displayUnit || 'MHz';
// --- FIX FOR BÅND-NAVN ---
// Hvis navnet mangler, sjekker vi om keyen slutter på 'm' (f.eks '31m').
// Da erstatter vi 'm' med ' meter'. Hvis ikke, bruker vi 'BAND'.
if (bandData.name) {
this.currentName = bandData.name;
} else if (bandKey && bandKey.endsWith('m')) {
this.currentName = bandKey.replace('m', ' meter');
} else {
this.currentName = 'BAND';
}
// -------------------------
if (this.isVisible && this.scaleCanvas) {
this.resizeCanvas();
}
}
if (pluginConfig.ANALOG_SCALE_AUTOSTART && !this._hasAutostarted) {
this._hasAutostarted = true;
setTimeout(() => {
if (!this.isVisible && !this.isFading) this.toggle();
}, 1200);
}
},
getStep() {
if (this.currentKey === 'FM') return (pluginConfig.TUNING_STANDARD === 'americas') ? 0.200 : 0.100;
if (this.currentKey === 'MW') return (localStorage.getItem('mwStepPreference') === 'true' || pluginConfig.TUNING_STANDARD === 'americas') ? 0.010 : 0.009;
if (this.currentKey === 'LW') return 0.009;
if (this.currentKey === 'OIRT') return 0.030;
if (this.currentKey === 'AM_SUPER') return 0.001;
return 0.005;
},
snapFreq(f) {
const step = this.getStep();
let snappedOffset = Math.round((f - this.currentMin) / step) * step;
let snappedFreq = this.currentMin + snappedOffset;
return parseFloat(Math.max(this.currentMin, Math.min(this.currentMax, snappedFreq)).toFixed(3));
},
getTheme() {
const cssVar = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
return {
bg1: cssVar("--color-1", "#071c33"),
bg2: cssVar("--color-2", "#0d2640"),
accent: cssVar("--color-4", "#3abf9a"),
text: cssVar("--color-text", "#e0e0e0")
};
},
parseColor(cssColor) {
if (this._colorCache[cssColor]) return this._colorCache[cssColor];
const tmp = document.createElement("canvas");
tmp.width = tmp.height = 1;
const c = tmp.getContext("2d");
c.fillStyle = "#000"; c.fillRect(0,0,1,1);
c.fillStyle = cssColor; c.fillRect(0,0,1,1);
const d = c.getImageData(0,0,1,1).data;
const result = { r: d[0], g: d[1], b: d[2] };
this._colorCache[cssColor] = result;
return result;
},
rgba(cssColor, alpha) {
const { r, g, b } = this.parseColor(cssColor);
return `rgba(${r},${g},${b},${alpha})`;
},
getClientX(evt) { return (evt.touches && evt.touches.length > 0) ? evt.touches[0].clientX : evt.clientX; },
getClientY(evt) { return (evt.touches && evt.touches.length > 0) ? evt.touches[0].clientY : evt.clientY; },
getAngle(x, y) { return Math.atan2(y - this.knobY, x - this.knobX); },
updateMetrics(sW, sH, kW) {
this.mX = 15;
this.mY = sH * 0.10;
this.mH = sH * 0.80;
if (kW > 0) {
this.mW = sW - this.mX - 15;
this.knobX = kW * 0.50;
this.knobY = sH * 0.5;
this.knobOuterR = Math.min(kW * 0.43, sH * 0.43);
this.knobInnerR = this.knobOuterR * 0.65;
} else {
this.mW = sW - (this.mX * 2);
}
},
ensureElements() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return false;
if (document.getElementById("et-analog-scale-wrap")) {
this.wrap = document.getElementById("et-analog-scale-wrap");
this.applyScaleLayout();
return true;
}
this.wrap = document.createElement("div");
this.wrap.id = "et-analog-scale-wrap";
Object.assign(this.wrap.style, {
display: "none", flexDirection: "row", width: "100%", height: "160px", position: "relative",
zIndex: "5",
transform: "translateX(10px)",
margin: "0",
overflow: "hidden", boxSizing: "border-box", userSelect: "none", webkitUserSelect: "none",
borderRadius: "16px", boxShadow: "0 6px 15px rgba(0,0,0,0.4)",
backgroundColor: "var(--color-1)"
});
const scaleDiv = document.createElement("div");
scaleDiv.id = "et-analog-scale-container";
Object.assign(scaleDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.scaleCanvas = document.createElement("canvas");
this.scaleCanvas.id = "et-analog-scale-canvas";
Object.assign(this.scaleCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
scaleDiv.appendChild(this.scaleCanvas);
this.wrap.appendChild(scaleDiv);
const knobDiv = document.createElement("div");
knobDiv.id = "et-analog-knob-container";
Object.assign(knobDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.knobCanvas = document.createElement("canvas");
this.knobCanvas.id = "et-analog-knob-canvas";
Object.assign(this.knobCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
knobDiv.appendChild(this.knobCanvas);
this.wrap.appendChild(knobDiv);
const vuDiv = document.createElement("div");
vuDiv.id = "et-analog-vu-container";
Object.assign(vuDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.vuCanvas = document.createElement("canvas");
this.vuCanvas.id = "et-analog-vu-canvas";
Object.assign(this.vuCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
vuDiv.appendChild(this.vuCanvas);
this.wrap.appendChild(vuDiv);
cc.appendChild(this.wrap);
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(() => {
if (this.isVisible) this.resizeCanvas();
});
this._resizeObserver.observe(this.wrap);
window.addEventListener('resize', () => {
if (this.isVisible) this.resizeCanvas();
});
}
this.scaleCanvas.addEventListener("wheel", (evt) => {
if (this.isDraggingScale) return;
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const showStations = (isFmSpectrum && this.isPsEnabled()) || (!isFmSpectrum && this.isSwScaleEnabled());
if (!showStations) return;
evt.preventDefault();
this.targetStationScrollY += Math.sign(evt.deltaY) * 35;
this.targetStationScrollY = Math.max(0, this.targetStationScrollY);
}, { passive: false });
this.scaleCanvas.addEventListener("mousemove", (evt) => {
if (this.isDraggingScale || this.isDraggingOuterKnob || this.isDraggingInnerKnob) return;
const rect = this.scaleCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
let isOverStation = false;
for (let st of this.renderedStations) {
if (x >= st.left && x <= st.right && y >= st.top && y <= st.bottom) {
isOverStation = true; break;
}
}
if (isOverStation) {
this.scaleCanvas.style.cursor = "pointer"; return;
}
const sdrBtn = document.getElementById('spectrum-graph-button');
const isSpecActive = sdrBtn && (sdrBtn.classList.contains('active') || sdrBtn.classList.contains('bg-color-4'));
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const showStations = (isFmSpectrum && this.isPsEnabled()) || (!isFmSpectrum && this.isSwScaleEnabled());
const paperX = this.mX + 2;
const paperW = this.mW - 4;
const paperH = this.mH - 4;
const baseY = showStations ? (this.mY + 2 + paperH * 0.35) : (this.mY + 2 + paperH * 0.85);
const actionBtnY = baseY - (showStations ? 5 : 15);
const scanBtnLeft = paperX + paperW - 40;
const scanBtnRight = paperX + paperW;
const scanBtnTop = actionBtnY - 15;
const scanBtnBottom = actionBtnY + 15;
const clearBtnLeft = paperX;
const clearBtnRight = paperX + 40;
const clearBtnTop = actionBtnY - 15;
const clearBtnBottom = actionBtnY + 15;
let hoverScan = isFmSpectrum && isSpecActive && x >= scanBtnLeft && x <= scanBtnRight && y >= scanBtnTop && y <= scanBtnBottom;
let hoverClear = isFmSpectrum && this.isPsEnabled() && x >= clearBtnLeft && x <= clearBtnRight && y >= clearBtnTop && y <= clearBtnBottom;
if (hoverScan || hoverClear) {
this.scaleCanvas.style.cursor = "pointer";
if (this.scanHovered !== hoverScan || this.clearHovered !== hoverClear) {
this.scanHovered = hoverScan;
this.clearHovered = hoverClear;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
return;
} else if (this.scanHovered || this.clearHovered) {
this.scanHovered = false; this.clearHovered = false;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
const tX = paperX + paperW * 0.04;
const tW = paperW * 0.92;
const needleX = tX + ((this.animFreq - this.currentMin) / (this.currentMax - this.currentMin)) * tW;
if (x >= needleX - 10 && x <= needleX + 10 && y >= this.mY && y <= this.mY + this.mH) {
this.scaleCanvas.style.cursor = "ew-resize";
} else {
this.scaleCanvas.style.cursor = "default";
}
});
this.scaleCanvas.addEventListener("mouseleave", () => {
this.scanHovered = false; this.clearHovered = false;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
const tooltip = document.getElementById('retro-station-tooltip');
if (tooltip) tooltip.style.display = 'none';
this.hoveredFreq = null;
});
const startScaleDrag = (evt) => {
const rect = this.scaleCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
for (let st of this.renderedStations) {
if (x >= st.left && x <= st.right && y >= st.top && y <= st.bottom) {
let targetFreq = this.currentUnit === 'kHz' ? st.f / 1000 : st.f;
safeTuneToFrequency(targetFreq);
this.currentFreq = targetFreq; this.animFreq = targetFreq; this.dragFreq = targetFreq;
return;
}
}
if (this.scanHovered) {
const origBtn = document.getElementById('spectrum-scan-button');
if (origBtn) origBtn.click();
this.scanClickedTime = Date.now();
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
return;
}
if (this.clearHovered) {
if (confirm("Are you sure you want to clear all saved PS stations?")) {
localStorage.removeItem('retro_station_db');
this.clearClickedTime = Date.now();
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
return;
}
if (x >= this.mX && x <= this.mX + this.mW && y >= this.mY && y <= this.mY + this.mH) {
this.isDraggingScale = true;
this.scaleCanvas.style.cursor = "col-resize";
this.handleGlobalMove(evt);
}
};
this.scaleCanvas.addEventListener("mousedown", startScaleDrag);
this.scaleCanvas.addEventListener("touchstart", startScaleDrag, { passive: false });
if (this.knobCanvas) {
this.knobCanvas.addEventListener("mousemove", (evt) => {
if (this.isDraggingScale || this.isDraggingOuterKnob || this.isDraggingInnerKnob) return;
const rect = this.knobCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const distSq = (x - this.knobX) * (x - this.knobX) + (y - this.knobY) * (y - this.knobY);
if (distSq <= this.knobOuterR * this.knobOuterR) {
this.knobCanvas.style.cursor = "grab";
} else {
this.knobCanvas.style.cursor = "default";
}
});
const startKnobDrag = (evt) => {
const rect = this.knobCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
const dx = x - this.knobX;
const dy = y - this.knobY;
const distSq = dx * dx + dy * dy;
this.knobDragMoved = false;
this.outerVelocity = 0;
this.innerVelocity = 0;
this.lastDragTime = Date.now();
if (distSq <= this.knobInnerR * this.knobInnerR) {
this.isDraggingInnerKnob = true;
this.lastDragAngle = this.getAngle(x, y);
this.knobCanvas.style.cursor = "grabbing";
} else if (distSq <= this.knobOuterR * this.knobOuterR) {
this.isDraggingOuterKnob = true;
this.accumulatedOuterAngle = 0;
this.lastDragAngle = this.getAngle(x, y);
this.knobCanvas.style.cursor = "grabbing";
}
};
this.knobCanvas.addEventListener("mousedown", startKnobDrag);
this.knobCanvas.addEventListener("touchstart", startKnobDrag, { passive: false });
this.knobCanvas.addEventListener("dblclick", (evt) => {
const rect = this.knobCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const distSq = (x - this.knobX) * (x - this.knobX) + (y - this.knobY) * (y - this.knobY);
if (distSq <= this.knobInnerR * this.knobInnerR && this.currentFreq !== null) {
let snappedFreq = this.snapFreq(this.currentFreq);
this.currentFreq = snappedFreq; this.animFreq = snappedFreq; this.dragFreq = snappedFreq;
safeTuneToFrequency(snappedFreq);
}
});
}
const stopDrag = () => {
const wasActivelyDragging = (this.isDraggingScale) || ((this.isDraggingInnerKnob || this.isDraggingOuterKnob) && this.knobDragMoved);
if (Date.now() - this.lastDragTime > 50) {
this.outerVelocity = 0;
this.innerVelocity = 0;
}
if ((this.isDraggingInnerKnob || this.isDraggingOuterKnob) && !this.knobDragMoved) {
this.isFineTuningMode = !this.isFineTuningMode;
if (navigator.vibrate) navigator.vibrate(50);
if (this.knobCanvas) this.drawKnob(this.knobCanvas);
}
this.isDraggingOuterKnob = false;
this.isDraggingInnerKnob = false;
this.isDraggingScale = false;
if (this.scaleCanvas) this.scaleCanvas.style.cursor = "default";
if (this.knobCanvas) this.knobCanvas.style.cursor = "default";
clearTimeout(this.finalTuneTimer);
if (wasActivelyDragging && this.dragFreq !== null) {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}
};
window.addEventListener("mousemove", (e) => this.handleGlobalMove(e));
window.addEventListener("mouseup", stopDrag);
window.addEventListener("mouseleave", stopDrag);
window.addEventListener("touchmove", (e) => this.handleGlobalMove(e), { passive: false });
window.addEventListener("touchend", stopDrag);
window.addEventListener("touchcancel", stopDrag);
this.applyScaleLayout();
this.resizeCanvas();
return true;
},
applyKnobRotation(isOuter, deltaAngle) {
let f = this.currentFreq;
const step = this.getStep();
if (isOuter) {
this.outerKnobAngle += deltaAngle;
this.accumulatedOuterAngle += deltaAngle;
const anglePerStep = Math.PI / 20;
if (Math.abs(this.accumulatedOuterAngle) >= anglePerStep) {
const steps = Math.trunc(this.accumulatedOuterAngle / anglePerStep);
this.accumulatedOuterAngle -= steps * anglePerStep;
f = this.snapFreq(f);
f += steps * step;
}
} else {
this.innerKnobAngle += deltaAngle;
if (typeof this.accumulatedInnerAngle === 'undefined') {
this.accumulatedInnerAngle = 0;
}
this.accumulatedInnerAngle += deltaAngle;
const anglePerInnerStep = Math.PI / 6;
if (Math.abs(this.accumulatedInnerAngle) >= anglePerInnerStep) {
const steps = Math.trunc(this.accumulatedInnerAngle / anglePerInnerStep);
this.accumulatedInnerAngle -= steps * anglePerInnerStep;
if (!this.isFineTuningMode) {
f = this.snapFreq(f);
f += steps * step;
} else {
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const fineStep = isFmSpectrum ? 0.010 : 0.001;
f += steps * fineStep;
}
}
}
f = Math.max(this.currentMin, Math.min(this.currentMax, f));
f = parseFloat(f.toFixed(3));
if (this.dragFreq !== f) {
this.dragFreq = f;
this.currentFreq = f;
const now = Date.now();
if (now - this.lastTuneTime > 80) { safeTuneToFrequency(f); this.lastTuneTime = now; }
clearTimeout(this.finalTuneTimer);
this.finalTuneTimer = setTimeout(() => {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}, 100);
}
},
handleGlobalMove(evt) {
if (!this.scaleCanvas || this.currentFreq === null) return;
if (this.isDraggingOuterKnob || this.isDraggingInnerKnob || this.isDraggingScale) {
if (evt.cancelable) evt.preventDefault();
} else {
return;
}
if (this.isDraggingOuterKnob || this.isDraggingInnerKnob) {
const rect = this.knobCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
const currentAngle = this.getAngle(x, y);
let deltaAngle = currentAngle - this.lastDragAngle;
if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI;
if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI;
if (Math.abs(deltaAngle) > 0.02) {
this.knobDragMoved = true;
}
this.lastDragTime = Date.now();
if (this.isDraggingOuterKnob) {
this.outerVelocity = deltaAngle;
this.applyKnobRotation(true, deltaAngle);
} else {
this.innerVelocity = deltaAngle;
this.applyKnobRotation(false, deltaAngle);
}
this.lastDragAngle = currentAngle;
} else if (this.isDraggingScale) {
const rect = this.scaleCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const paperX = this.mX + 2;
const paperW = this.mW - 4;
const tX = paperX + paperW * 0.04;
const tW = paperW * 0.92;
let rawF = this.currentMin + ((x - tX) / tW) * (this.currentMax - this.currentMin);
let f = this.isFineTuningMode ? rawF : this.snapFreq(rawF);
f = Math.max(this.currentMin, Math.min(this.currentMax, f));
f = parseFloat(f.toFixed(3));
if (this.dragFreq !== f) {
this.dragFreq = f;
this.currentFreq = f;
if (this.isDraggingScale) this.animFreq = f;
const now = Date.now();
if (now - this.lastTuneTime > 80) { safeTuneToFrequency(f); this.lastTuneTime = now; }
clearTimeout(this.finalTuneTimer);
this.finalTuneTimer = setTimeout(() => {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}, 100);
}
}
},
resizeCanvas() {
if (!this.scaleCanvas || !this.wrap) return;
const dpr = window.devicePixelRatio || 1;
const sW = this.scaleCanvas.parentElement.clientWidth;
const sH = this.scaleCanvas.parentElement.clientHeight || 160;
if(sW === 0 || sH === 0) return;
this.scaleCanvas.width = Math.round(sW * dpr);
this.scaleCanvas.height = Math.round(sH * dpr);
let kW = 0;
if (this.knobCanvas && this.knobCanvas.parentElement.style.display !== "none") {
kW = this.knobCanvas.parentElement.clientWidth || 96;
const kH = this.knobCanvas.parentElement.clientHeight || 160;
this.knobCanvas.width = Math.round(kW * dpr);
this.knobCanvas.height = Math.round(kH * dpr);
}
if (this.isVuEnabled() && this.vuCanvas) {
const vW = this.vuCanvas.parentElement.clientWidth;
const vH = this.vuCanvas.parentElement.clientHeight || 160;
this.vuCanvas.width = Math.round(vW * dpr);
this.vuCanvas.height = Math.round(vH * dpr);
}
this.updateMetrics(sW, sH, kW);
this.lastKnobState = "";
},
applyWrapperStyle() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return;
cc.style.padding = "0";
cc.style.lineHeight = "0";
cc.style.overflow = "visible";
cc.style.position = "relative";
cc.style.height = "auto";
cc.style.minHeight = "160px";
cc.style.flexShrink = "0";
cc.style.marginTop = "10px";
cc.style.marginBottom = "0px";
const p = cc.parentElement;
if (p) {
const cs = getComputedStyle(p);
const pL = parseFloat(cs.paddingLeft) || 0;
const pR = parseFloat(cs.paddingRight) || 0;
cc.style.marginLeft = `-${pL}px`;
cc.style.marginRight = `-${pR}px`;
cc.style.width = `calc(98.4% + ${pL + pR}px)`;
}
},
resetWrapperStyle() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return;
const mmActive =["mm-mpx-combo-flex", "mm-signal-analyzer-flex", "mm-scope-flex"].some(id => {
const el = document.getElementById(id);
return el && el.style.display === "flex";
});
if (!mmActive) {["padding", "margin", "marginTop", "marginBottom", "lineHeight", "overflow", "marginLeft", "marginRight", "width", "position", "height", "minHeight", "flexShrink"].forEach(p => cc.style[p] = "");
}
},
toggle() {
if (this.isFading) return;
const btn = document.getElementById("et-analog-scale-btn");
if (this.isVisible) {
this.isFading = true;
if (this.wrap) {
this.wrap.style.transition = "opacity 400ms ease-in-out";
this.wrap.style.opacity = "0";
}
setTimeout(() => {
this.isVisible = false;
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
if (this.wrap) { this.wrap.style.display = "none"; this.wrap.style.transition = ""; }
document.body.classList.remove('et-analog-active');
this.resetWrapperStyle();
if (btn) btn.classList.remove("active");
this.isFading = false;
}, 400);
} else {
if (!this.ensureElements()) return;
if (this.audioCtx && this.audioCtx.state === 'suspended') this.audioCtx.resume();
document.body.classList.add('et-analog-active');
this.applyWrapperStyle();
this.wrap.style.display = "flex";
this.isVisible = true;
this.resizeCanvas();
if (!this.rafId) this.rafId = requestAnimationFrame(() => this.renderLoop());
this.wrap.style.opacity = "0";
this.wrap.style.transition = "opacity 400ms ease-in-out";
// --- DEN GENIALE ENDRINGEN ---
// Vi setter opacity til 0.99 i stedet for 1!
// Dette tvinger Chromium til å holde spektrumet bak skalaen i live!
requestAnimationFrame(() => requestAnimationFrame(() => { this.wrap.style.opacity = "0.99"; }));
setTimeout(() => { if (this.wrap) this.wrap.style.transition = ""; this.isFading = false; }, 400);
if (btn) btn.classList.add("active");
}
},
rrect(ctx, x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();
},
drawSilverKnurledRing(ctx, radius, width, ridges, angleOffset) {
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.arc(0, 0, radius - width, 0, Math.PI * 2, true);
const silverGrad = ctx.createLinearGradient(-radius, -radius, radius, radius);
silverGrad.addColorStop(0, "#777777"); silverGrad.addColorStop(0.2, "#aaaaaa");
silverGrad.addColorStop(0.5, "#555555"); silverGrad.addColorStop(0.8, "#bbbbbb"); silverGrad.addColorStop(1, "#444444");
ctx.fillStyle = silverGrad; ctx.fill();
ctx.lineWidth = 1.0;
for (let i = 0; i < ridges; i++) {
const angle = (i / ridges) * Math.PI * 2 + angleOffset;
const x1 = Math.cos(angle) * radius, y1 = Math.sin(angle) * radius;
const x2 = Math.cos(angle) * (radius - width), y2 = Math.sin(angle) * (radius - width);
ctx.strokeStyle = "rgba(0,0,0,0.6)"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const a2 = angle + (Math.PI * 2 / ridges) * 0.4;
const x3 = Math.cos(a2) * radius, y3 = Math.sin(a2) * radius;
const x4 = Math.cos(a2) * (radius - width), y4 = Math.sin(a2) * (radius - width);
ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.beginPath(); ctx.moveTo(x3, y3); ctx.lineTo(x4, y4); ctx.stroke();
}
},
drawConcentricKnob(ctx, x, y, outerR, innerR, T) {
ctx.save(); ctx.translate(x, y);
ctx.beginPath(); ctx.arc(0, 0, outerR + 2, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 12; ctx.shadowOffsetY = 6; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, outerR, 0, Math.PI * 2);
ctx.lineWidth = 1.5; ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.stroke();
this.drawSilverKnurledRing(ctx, outerR, outerR * 0.07, 80, this.outerKnobAngle);
ctx.beginPath(); ctx.arc(0, 0, outerR * 0.93, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 6; ctx.shadowOffsetY = 2;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 2; ctx.stroke(); ctx.shadowColor = "transparent";
const outerDarkR = outerR * 0.93;
ctx.beginPath(); ctx.arc(0, 0, outerDarkR, 0, Math.PI * 2);
const outerGrad = ctx.createLinearGradient(-outerDarkR, -outerDarkR, outerDarkR, outerDarkR);
outerGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); outerGrad.addColorStop(0.5, this.rgba(T.bg1, 0.9)); outerGrad.addColorStop(1, this.rgba(T.bg1, 1.0));
ctx.fillStyle = outerGrad; ctx.fill();
const outerDimpleX = Math.cos(this.outerKnobAngle - Math.PI / 2) * (outerDarkR * 0.82);
const outerDimpleY = Math.sin(this.outerKnobAngle - Math.PI / 2) * (outerDarkR * 0.82);
ctx.beginPath(); ctx.arc(outerDimpleX, outerDimpleY, outerR * 0.06, 0, Math.PI * 2);
const outerDimpleGrad = ctx.createRadialGradient(outerDimpleX, outerDimpleY, 0, outerDimpleX, outerDimpleY, outerR * 0.06);
if (!this.isFineTuningMode) {
outerDimpleGrad.addColorStop(0, "#ffffff");
outerDimpleGrad.addColorStop(1, "#3498db");
ctx.shadowColor = "#3498db"; ctx.shadowBlur = 8;
} else {
outerDimpleGrad.addColorStop(0, "#111");
outerDimpleGrad.addColorStop(1, this.rgba(T.bg2, 1.0));
}
ctx.fillStyle = outerDimpleGrad; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(outerDimpleX, outerDimpleY, outerR * 0.06, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 1;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.stroke(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, innerR + 3, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,1.0)"; ctx.shadowColor = "rgba(0,0,0,0.95)";
ctx.shadowBlur = 18; ctx.shadowOffsetY = 10; ctx.shadowOffsetX = 4; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, innerR + 1, 0, Math.PI * 1); ctx.fillStyle = "#000000"; ctx.fill();
ctx.beginPath(); ctx.arc(0, 0, innerR + 1, 0, Math.PI * 2);
const rimGrad = ctx.createLinearGradient(0, -innerR, 0, innerR);
rimGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); rimGrad.addColorStop(1, "#111"); ctx.fillStyle = rimGrad; ctx.fill();
this.drawSilverKnurledRing(ctx, innerR, innerR * 0.10, 60, this.innerKnobAngle);
ctx.beginPath(); ctx.arc(0, 0, innerR * 0.90, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2;
ctx.strokeStyle = "rgba(0,0,0,0.6)"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.shadowColor = "transparent";
const innerDarkR = innerR * 0.90;
ctx.beginPath(); ctx.arc(0, 0, innerDarkR, 0, Math.PI * 2);
const innerGrad = ctx.createLinearGradient(-innerDarkR, -innerDarkR, innerDarkR, innerDarkR);
innerGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); innerGrad.addColorStop(0.6, this.rgba(T.bg1, 0.9)); innerGrad.addColorStop(1, this.rgba(T.bg1, 1.0));
ctx.fillStyle = innerGrad; ctx.fill();
ctx.beginPath(); ctx.arc(0, 0, innerDarkR, 0, Math.PI * 2); ctx.lineWidth = 1;
const highlightGrad = ctx.createLinearGradient(0, -innerDarkR, 0, innerDarkR);
highlightGrad.addColorStop(0, "rgba(255,255,255,0.4)"); highlightGrad.addColorStop(0.3, "rgba(255,255,255,0.05)"); highlightGrad.addColorStop(1, "rgba(255,255,255,0)");
ctx.strokeStyle = highlightGrad; ctx.stroke();
const dimpleX = Math.cos(this.innerKnobAngle - Math.PI / 2) * (innerDarkR * 0.7);
const dimpleY = Math.sin(this.innerKnobAngle - Math.PI / 2) * (innerDarkR * 0.7);
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
const dimpleGrad = ctx.createRadialGradient(dimpleX, dimpleY, 0, dimpleX, dimpleY, innerR * 0.08);
if (this.isFineTuningMode) {
dimpleGrad.addColorStop(0, "#ffffff");
dimpleGrad.addColorStop(1, "#3498db");
ctx.shadowColor = "#3498db"; ctx.shadowBlur = 8;
} else {
dimpleGrad.addColorStop(0, "#111");
dimpleGrad.addColorStop(1, this.rgba(T.bg2, 1.0));
}
ctx.fillStyle = dimpleGrad; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 1;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.stroke(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 0.5; ctx.stroke();
ctx.restore();
},
drawScale(canvas, freq) {
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const CW = canvas.width / dpr;
const CH = canvas.height / dpr;
if(CW === 0 || CH === 0) return;
const T = this.getTheme();
let dMin = this.currentUnit === 'kHz' ? this.currentMin * 1000 : this.currentMin;
let dMax = this.currentUnit === 'kHz' ? this.currentMax * 1000 : this.currentMax;
let dFreq = this.currentUnit === 'kHz' ? freq * 1000 : freq;
let dRange = dMax - dMin;
let majStep, midStep, minStep, lblStep;
if (dRange > 1000) { lblStep = 100; majStep = 100; midStep = 50; minStep = 10; }
else if (dRange > 100) { lblStep = 20; majStep = 20; midStep = 10; minStep = 2; }
else if (dRange > 15) { lblStep = 2; majStep = 2; midStep = 1; minStep = 0.1; }
else if (dRange > 5) { lblStep = 1; majStep = 1; midStep = 0.5; minStep = 0.1; }
else { lblStep = 0.1; majStep = 0.1; midStep = 0.05; minStep = 0.01; }
ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, CW, CH);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fillRect(0, 0, CW, CH);
const mR = 8;
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.85)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 4; ctx.shadowOffsetX = 2;
this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fill();
ctx.restore();
ctx.save(); ctx.filter = `brightness(${this.getBrightness()})`;
const paperX = this.mX + 2, paperY = this.mY + 2, paperW = this.mW - 4, paperH = this.mH - 4;
ctx.fillStyle = this.rgba(T.bg2, 0.5);
this.rrect(ctx, paperX, paperY, paperW, paperH, 2); ctx.fill();
const paper = ctx.createRadialGradient(paperX + paperW * 0.5, paperY + paperH * 0.5, paperH * 0.1, paperX + paperW * 0.5, paperY + paperH * 0.5, paperW * 0.65);
paper.addColorStop(0.00, this.rgba(T.accent, 0.80)); paper.addColorStop(0.40, this.rgba(T.accent, 0.50)); paper.addColorStop(1.00, this.rgba(T.accent, 0.10));
ctx.fillStyle = paper; this.rrect(ctx, paperX, paperY, paperW, paperH, 2); ctx.fill();
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const psMode = isFmSpectrum && this.isPsEnabled();
const swMode = !isFmSpectrum && this.currentKey.endsWith('m') && this.isSwScaleEnabled();
const mwLwMode = !isFmSpectrum && (this.currentKey === 'MW' || this.currentKey === 'LW') && this.isSwScaleEnabled();
const showStationsOnScale = psMode || swMode || mwLwMode;
const tX = paperX + paperW * 0.04, tW = paperW * 0.92;
const baseY = showStationsOnScale ? paperY + paperH * 0.35 : paperY + paperH * 0.85;
const numY = showStationsOnScale ? paperY + paperH * 0.15 : paperY + paperH * 0.32;
const fX = f => tX + ((f - dMin) / dRange) * tW;
if (isFmSpectrum && this.isSpectrumEnabled()) {
try {
const sdrCanvas = document.getElementById('sdr-graph');
const specBtn = document.getElementById('spectrum-graph-button');
if (sdrCanvas && specBtn && (specBtn.classList.contains('active') || specBtn.classList.contains('bg-color-4')) && sdrCanvas.width > 100) {
// 1. Les av nøyaktig hvilke frekvenser original-canvaset spenner over
const specContainer = sdrCanvas.closest('.canvas-container') || sdrCanvas.parentElement;
let ariaLabel = specContainer ? specContainer.getAttribute('aria-label') : '';
let srcMin = 86.0; // Fallback
let srcMax = 108.0;
if (ariaLabel) {
// Trekker ut minFreq og maxFreq fra "aria-label" attributtet
const match = ariaLabel.match(/from ([\d.]+) to ([\d.]+) MHz/);
if (match) {
srcMin = parseFloat(match[1]);
srcMax = parseFloat(match[2]);
}
} else {
// Siste utvei-fallback hvis aria-label mangler
const limitSpan = document.querySelector(".tuner-desc .text-small .color-4");
if (limitSpan) {
const match = limitSpan.textContent.match(/(\d+(\.\d+)?)\s*MHz\s*-\s*(\d+(\.\d+)?)/);
if (match) {
srcMin = Math.max(Number(match[1]), 86);
srcMax = Number(match[3]);
}
}
}
// 2. Hent xOffset-avstanden for Y-aksen (eksakt som i orginal-koden)
const signalText = localStorage.getItem('signalUnit') || 'dbf';
const xOffset = (signalText === 'dbm') ? 36 : 30;
// 3. Definer området i kilde-canvaset som kun inneholder graf-data
const srcX = xOffset;
const srcY = 16;
const srcW = sdrCanvas.width - xOffset;
const srcH = sdrCanvas.height - 40;
// 4. Kalkuler hvor på VÅR skala disse frekvensene befinner seg
const dMin = this.currentUnit === 'kHz' ? this.currentMin * 1000 : this.currentMin;
const dMax = this.currentUnit === 'kHz' ? this.currentMax * 1000 : this.currentMax;
// Matematisk mapping av startposisjon og bredde
const destX = tX + ((srcMin - dMin) / (dMax - dMin)) * tW;
const destW = ((srcMax - srcMin) / (dMax - dMin)) * tW;
if (srcW > 0 && srcH > 0) {
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
ctx.filter = 'invert(1) grayscale(1) contrast(2.0) brightness(0.9)';
ctx.globalAlpha = 0.3;
ctx.globalCompositeOperation = 'multiply';
// Tegn bildet strukket nøyaktig til de riktige matematiske punktene!
ctx.drawImage(sdrCanvas, srcX, srcY, srcW, srcH, destX, paperY + 2, destW, (baseY - paperY) - 2);
ctx.restore();
}
}
} catch (e) {}
}
const inkColor = "rgba(10, 15, 20, 0.95)";
const inkFaded = "rgba(10, 15, 20, 0.70)";
ctx.beginPath(); ctx.moveTo(tX, baseY); ctx.lineTo(tX + tW, baseY);
ctx.strokeStyle = inkFaded; ctx.lineWidth = 2.0; ctx.stroke();
for (let i = Math.floor(dMin / minStep); i <= Math.ceil(dMax / minStep); i++) {
const f = i * minStep;
if (f < dMin - 0.001 || f > dMax + 0.001) continue;
const x = fX(f);
const isMaj = Math.abs(Math.round(f / majStep) * majStep - f) < 0.001;
const isMid = !isMaj && Math.abs(Math.round(f / midStep) * midStep - f) < 0.001;
let tH = paperH * (showStationsOnScale ? 0.03 : 0.06), col = "rgba(10,15,20,0.5)", lw = 1.0;
if (showStationsOnScale) {
if (isMaj) { tH = paperH * 0.10; col = inkColor; lw = 2.0; } else if (isMid) { tH = paperH * 0.06; col = inkColor; lw = 1.5; }
} else {
if (isMaj) { tH = paperH * 0.35; col = inkColor; lw = 2.0; } else if (isMid) { tH = paperH * 0.22; col = inkColor; lw = 1.5; }
}
ctx.beginPath(); ctx.moveTo(x, baseY); ctx.lineTo(x, baseY - tH);
ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.stroke();
}
this.renderedStations =[];
if (showStationsOnScale) {
let visibleStations =[];
if (psMode) {
const stationDB = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
for (let fStr in stationDB) {
let stFreq = parseFloat(fStr);
if (stFreq >= dMin - 0.2 && stFreq <= dMax + 0.2) {
visibleStations.push({ freq: stFreq, name: stationDB[fStr].ps, x: fX(stFreq), data: stationDB[fStr], isSw: false });
}
}
} else if (swMode || mwLwMode) {
let activeList;
if (swMode) {
activeList = this.swStations;
} else {
activeList = this.mwLwFavorites;
}
if (activeList !== null && activeList !== undefined) {
for (let i = 0; i < activeList.length; i++) {
let st = activeList[i];
let stFreq;
// SW bruker MHz (må deles på 1000), MW/LW bruker kHz direkte
if (mwLwMode) {
stFreq = st.frequency;
} else {
stFreq = st.frequency / 1000;
}
if (stFreq >= dMin - 0.05 && stFreq <= dMax + 0.05) {
let shortName = st.name.replace(/(Radio|Broadcasting|Corporation|Voice of|International)/ig, '').trim();
if (shortName.startsWith('-') || shortName.startsWith(',')) {
shortName = shortName.substring(1).trim();
}
if (shortName.length > 10) {
shortName = shortName.substring(0, 9) + '..';
}
if (shortName === '') {
shortName = st.name.substring(0, 10);
}
visibleStations.push({ freq: stFreq, name: shortName, x: fX(stFreq), data: st, isSw: true });
}
}
}
}
visibleStations.sort((a,b) => a.x - b.x);
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
const startY = baseY + paperH * 0.02;
const baseRowHeight = ((paperY + paperH) - startY - (paperH * 0.02)) / 5;
const rowHeight = Math.min(baseRowHeight, 35);
const stFontSize = Math.max((this.isVuEnabled() ? 7 : 9), rowHeight * (this.isVuEnabled() ? 0.60 : 0.70));
ctx.textAlign = "center"; ctx.textBaseline = "middle";
let rowRightEdges =[];
let maxRow = 0;
const hoverThreshold = isFmSpectrum ? 0.04 : (this.currentUnit === 'kHz' ? 3 : 0.003);
visibleStations.forEach((st, index) => {
const isHovered = (this.hoveredFreq === st.freq) || (Math.abs(dFreq - st.freq) <= hoverThreshold);
const isActive = st.isSw ? true : (st.data.active !== false);
ctx.font = `${stFontSize}px "Arial Narrow", Arial, sans-serif`;
const baseBoxWidth = Math.max(ctx.measureText(this.isVuEnabled() ? "000000" : "00000000").width, ctx.measureText(st.name).width);
const baseTotalWidth = baseBoxWidth + ((this.isVuEnabled() ? 2 : 4) * 2);
let targetX = st.x;
if (targetX - (baseTotalWidth / 2) < paperX + 4) targetX = paperX + 4 + (baseTotalWidth / 2);
if (targetX + (baseTotalWidth / 2) > paperX + paperW - 4) targetX = paperX + paperW - 4 - (baseTotalWidth / 2);
let assignedRow = -1;
for (let r = 0; r < 200; r++) {
if (!rowRightEdges[r]) rowRightEdges[r] = -9999;
if (targetX - (baseTotalWidth / 2) > rowRightEdges[r] + (this.isVuEnabled() ? 6 : 8)) {
assignedRow = r; break;
}
}
if (assignedRow === -1) assignedRow = 0;
maxRow = Math.max(maxRow, assignedRow);
rowRightEdges[assignedRow] = Math.max(rowRightEdges[assignedRow], targetX + (baseTotalWidth / 2));
const y = startY + assignedRow * rowHeight + (rowHeight / 2) - this.stationScrollY;
if (y > paperY - rowHeight && y < paperY + paperH + rowHeight) {
const hoverFactor = 1.15;
ctx.font = `${isHovered ? 'bold ' : ''}${isHovered ? stFontSize * hoverFactor : stFontSize}px "Arial Narrow", Arial, sans-serif`;
// --- FIX FOR SW BAKGRUNNSBOKSER ---
// Bestemmer om den mørke bakgrunnsboksen skal tegnes.
// For SW: Kun når stasjonen er i fokus (isHovered). For FM: Når stasjonen er aktiv.
const drawBgBox = st.isSw ? isHovered : isActive;
if (drawBgBox) {
ctx.fillStyle = this.rgba(T.bg1, 0.20);
if (isHovered) { ctx.shadowColor = "rgba(0,0,0,0.3)"; ctx.shadowBlur = 4; }
this.rrect(ctx, targetX - baseBoxWidth/2 - 4, y - (stFontSize*1.05)/2 - (isHovered?1:0), baseBoxWidth + 8, (stFontSize*1.05) + (isHovered?2:0), 4);
ctx.fill();
ctx.shadowBlur = 0;
}
// Setter fargen på teksten.
// Alle SW-stasjoner og aktive FM-stasjoner forblir tydelige (inkColor), selv uten boks.
if (st.isSw || isActive) {
ctx.fillStyle = inkColor;
} else {
// Inaktive FM-stasjoner falmes som før
if (isHovered) { ctx.shadowColor = "rgba(0,0,0,0.5)"; ctx.shadowBlur = 4; }
ctx.fillStyle = inkFaded;
}
// ------------------------------------
const rangeScale = this.currentUnit === 'kHz' ? 1000.0 : 1.0;
ctx.fillText(st.name, targetX, y, ((rangeScale / dRange) * tW * 0.90) * (isHovered ? hoverFactor : 1));
ctx.shadowBlur = 0;
this.renderedStations.push({ f: st.freq, left: targetX - baseBoxWidth/2 - 4, right: targetX + baseBoxWidth/2 + 4, top: y - stFontSize, bottom: y + stFontSize, data: st.data, isSw: st.isSw });
}
});
ctx.restore();
const maxScroll = Math.max(0, (maxRow * rowHeight) - (paperH * 0.5));
if (this.targetStationScrollY > maxScroll) this.targetStationScrollY = maxScroll;
}
const numSize = Math.max(showStationsOnScale ? 10 : 12, Math.min(18, paperW / 55));
ctx.font = `700 ${numSize}px "Arial Narrow", Arial, sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = inkColor;
// Vi lagrer det første tallet som tegnes i en variabel (startNum)
const startNum = Math.ceil(dMin / lblStep) * lblStep;
for (let f = startNum; f <= dMax + 0.001; f += lblStep) {
ctx.fillText(lblStep < 1 ? f.toFixed(1) : Math.round(f).toString(), fX(f), numY);
}
const lblSize = Math.max(showStationsOnScale ? 9 : 10, paperH * (showStationsOnScale ? 0.14 : 0.20));
const smallLabelFont = `700 ${lblSize * (showStationsOnScale ? 0.8 : 0.6)}px Arial, sans-serif`;
if (showStationsOnScale) {
// --- FIX FOR MHz / kHz PLASSERING ---
// Vi regner ut X-posisjonen og bredden til det aller første tallet
const firstNumX = fX(startNum);
const firstNumText = lblStep < 1 ? startNum.toFixed(1) : Math.round(startNum).toString();
ctx.font = `700 ${numSize}px "Arial Narrow", Arial, sans-serif`;
const firstNumWidth = ctx.measureText(firstNumText).width;
ctx.textAlign = "left"; // Tegner teksten fra venstre mot høyre
ctx.textBaseline = "middle";
ctx.font = smallLabelFont;
ctx.fillStyle = inkColor;
// Vi plasserer "MHz" nøyaktig 4 piksler til HØYRE for det første tallet
const unitX = firstNumX + (firstNumWidth / 2) + 4;
ctx.fillText(this.currentUnit, unitX, numY);
// ------------------------------------
ctx.textBaseline = "bottom";
ctx.textAlign = "left";
ctx.font = `900 ${lblSize}px "Arial Narrow", Arial, sans-serif`;
ctx.fillText(this.currentName, paperX + 3, paperY + paperH - 4);
} else {
ctx.textBaseline = "top"; ctx.textAlign = "left"; ctx.font = `900 ${lblSize}px "Arial Narrow", Arial, sans-serif`; ctx.fillText(this.currentName, paperX + 8, paperY + 5);
ctx.textAlign = "right"; ctx.font = smallLabelFont; ctx.fillText(this.currentUnit, paperX + paperW - 8, paperY + 5);
}
const actionBtnY = baseY - (showStationsOnScale ? 5 : 15);
ctx.textBaseline = "middle";
if (isFmSpectrum) {
const sdrBtnDraw = document.getElementById('spectrum-graph-button');
if (sdrBtnDraw && (sdrBtnDraw.classList.contains('active') || sdrBtnDraw.classList.contains('bg-color-4'))) {
ctx.font = `700 ${lblSize * (showStationsOnScale ? 0.8 : 0.65)}px Arial, sans-serif`;
ctx.textAlign = "right";
ctx.fillStyle = (Date.now() - this.scanClickedTime < 150) ? "#ff3300" : (this.scanHovered ? inkColor : inkFaded);
ctx.fillText("↻ ", paperX + paperW - 5, actionBtnY);
}
}
if (isFmSpectrum && this.isPsEnabled()) {
ctx.font = `700 ${lblSize * 0.55}px Arial, sans-serif`;
ctx.textAlign = "left";
ctx.fillStyle = (Date.now() - this.clearClickedTime < 150) ? "#ff3300" : (this.clearHovered ? inkColor : inkFaded);
ctx.fillText(" ✖", paperX + 5, actionBtnY);
}
const nx = fX(Math.max(dMin, Math.min(dMax, dFreq)));
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.60)"; ctx.shadowBlur = 4; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 1;
ctx.beginPath(); ctx.moveTo(nx, this.mY - 1); ctx.lineTo(nx, this.mY + this.mH + 1);
ctx.strokeStyle = "#ff3300"; ctx.lineWidth = 2.5; ctx.stroke(); ctx.restore();
ctx.beginPath(); ctx.moveTo(nx, this.mY); ctx.lineTo(nx, this.mY + this.mH);
ctx.strokeStyle = "rgba(255,200,200,0.60)"; ctx.lineWidth = 0.9; ctx.stroke();
const blockH = CH * 0.09, blockW = 10;
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.70)"; ctx.shadowBlur = 4; ctx.shadowOffsetY = 2;
const topBlock = ctx.createLinearGradient(nx - blockW/2, 0, nx + blockW/2, 0);
topBlock.addColorStop(0, "#c01500"); topBlock.addColorStop(0.4, "#ff3a1a"); topBlock.addColorStop(0.6, "#ff3a1a"); topBlock.addColorStop(1, "#8a0d00");
ctx.fillStyle = topBlock; ctx.fillRect(nx - blockW / 2, 0, blockW, blockH); ctx.restore();
ctx.strokeStyle = "rgba(255,200,180,0.45)"; ctx.lineWidth = 0.7; ctx.strokeRect(nx - blockW / 2, 0, blockW, blockH);
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.70)"; ctx.shadowBlur = 4; ctx.shadowOffsetY = -1;
const botBlock = ctx.createLinearGradient(nx - 4, 0, nx + 4, 0);
botBlock.addColorStop(0, "#8a0d00"); botBlock.addColorStop(0.5, "#dd2a10"); botBlock.addColorStop(1, "#8a0d00");
ctx.fillStyle = botBlock; ctx.fillRect(nx - 4, CH - blockH * 0.85, 8, blockH * 0.85); ctx.restore();
ctx.restore();
ctx.save();
const bezelTop = ctx.createLinearGradient(0, this.mY, 0, this.mY + 6); bezelTop.addColorStop(0, "rgba(255,255,255,0.40)"); bezelTop.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = bezelTop; ctx.fillRect(this.mX, this.mY, this.mW, 6);
const bezelBot = ctx.createLinearGradient(0, this.mY + this.mH - 6, 0, this.mY + this.mH); bezelBot.addColorStop(0, "rgba(0,0,0,0.00)"); bezelBot.addColorStop(1, "rgba(0,0,0,0.60)");
ctx.fillStyle = bezelBot; ctx.fillRect(this.mX, this.mY + this.mH - 6, this.mW, 6); ctx.restore();
ctx.save(); this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR); ctx.strokeStyle = "rgba(255,255,255,0.15)"; ctx.lineWidth = 1; ctx.stroke();
this.rrect(ctx, this.mX + 1, this.mY + 1, this.mW - 2, this.mH - 2, mR - 1); ctx.strokeStyle = "rgba(0,0,0,0.50)"; ctx.lineWidth = 1; ctx.stroke(); ctx.restore();
ctx.save(); this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR); ctx.clip();
const glare = ctx.createLinearGradient(0, this.mY, 0, this.mY + this.mH * 0.22); glare.addColorStop(0, "rgba(255,255,255,0.08)"); glare.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = glare; ctx.fillRect(this.mX, this.mY, this.mW, this.mH * 0.22);
ctx.beginPath(); ctx.moveTo(this.mX + this.mW * 0.05, this.mY); ctx.lineTo(this.mX + this.mW * 0.30, this.mY); ctx.lineTo(this.mX + this.mW * 0.18, this.mY + this.mH * 0.55); ctx.lineTo(this.mX + this.mW * 0.00, this.mY + this.mH * 0.55); ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.03)"; ctx.fill(); ctx.restore();
ctx.restore();
},
drawKnob(canvas) {
if (!canvas) return false;
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const CW = canvas.width / dpr;
const CH = canvas.height / dpr;
if(CW === 0 || CH === 0) return false;
const T = this.getTheme();
ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, CW, CH);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fillRect(0, 0, CW, CH);
this.drawConcentricKnob(ctx, this.knobX, this.knobY, this.knobOuterR, this.knobInnerR, T);
ctx.restore();
return true;
},
drawVuMeter(canvas, levelL, levelR) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const cw = canvas.width;
const ch = canvas.height;
if(cw === 0 || ch === 0) return;
ctx.save();
ctx.scale(dpr, dpr);
const w = cw / dpr;
const h = ch / dpr;
const T = this.getTheme();
ctx.fillStyle = this.rgba(T.bg1, 1.0);
ctx.fillRect(0, 0, w, h);
const mR = 8;
const mX = 5;
const vY = this.mY;
const vH = this.mH;
const vW = w - 20
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.85)";
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
ctx.shadowOffsetX = 2;
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.fillStyle = this.rgba(T.bg1, 1.0);
ctx.fill();
ctx.restore();
ctx.save();
ctx.filter = `brightness(${this.getBrightness()})`;
const paperX = mX + 2;
const paperY = vY + 2;
const paperW = vW - 4;
const paperH = vH - 4;
ctx.fillStyle = this.rgba(T.bg2, 0.5);
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
const paper = ctx.createRadialGradient(paperX + paperW * 0.5, paperY + paperH * 0.5, paperH * 0.1, paperX + paperW * 0.5, paperY + paperH * 0.5, paperW * 0.65);
paper.addColorStop(0.00, this.rgba(T.accent, 0.80));
paper.addColorStop(0.40, this.rgba(T.accent, 0.50));
paper.addColorStop(1.00, this.rgba(T.accent, 0.10));
ctx.fillStyle = paper;
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
const innerTop = ctx.createLinearGradient(0, paperY, 0, paperY + 15);
innerTop.addColorStop(0, "rgba(0,0,0,0.7)");
innerTop.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = innerTop;
ctx.fillRect(paperX, paperY, paperW, 15);
const vignette = ctx.createRadialGradient(paperX + paperW/2, paperY + paperH/2, paperW*0.3, paperX + paperW/2, paperY + paperH/2, paperW*0.6);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(1, "rgba(0,0,0,0.5)");
ctx.fillStyle = vignette;
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
ctx.restore();
const cx1 = paperX + paperW * 0.26;
const cx2 = paperX + paperW * 0.74;
const cy = paperY + paperH * 0.85;
const radius = Math.min(paperW * 0.21, paperH * 0.48);
const minAngle = -1.00;
const maxAngle = 1.00;
this.drawDial(ctx, cx1, cy, radius, minAngle, maxAngle);
this.drawDial(ctx, cx2, cy, radius, minAngle, maxAngle);
const angleL = minAngle + (levelL * (maxAngle - minAngle));
const angleR = minAngle + (levelR * (maxAngle - minAngle));
this.drawNeedle(ctx, cx1, cy, radius * 0.9, angleL);
this.drawNeedle(ctx, cx2, cy, radius * 0.9, angleR);
ctx.restore();
ctx.save();
const bezelTop = ctx.createLinearGradient(0, vY, 0, vY + 6);
bezelTop.addColorStop(0, "rgba(255,255,255,0.40)");
bezelTop.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = bezelTop;
ctx.fillRect(mX, vY, vW, 6);
const bezelBot = ctx.createLinearGradient(0, vY + vH - 6, 0, vY + vH);
bezelBot.addColorStop(0, "rgba(0,0,0,0.00)");
bezelBot.addColorStop(1, "rgba(0,0,0,0.60)");
ctx.fillStyle = bezelBot;
ctx.fillRect(mX, vY + vH - 6, vW, 6);
ctx.restore();
ctx.save();
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.strokeStyle = "rgba(255,255,255,0.15)";
ctx.lineWidth = 1;
ctx.stroke();
this.rrect(ctx, mX + 1, vY + 1, vW - 2, vH - 2, mR - 1);
ctx.strokeStyle = "rgba(0,0,0,0.50)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
ctx.save();
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.clip();
const glare = ctx.createLinearGradient(0, vY, 0, vY + vH * 0.22);
glare.addColorStop(0, "rgba(255,255,255,0.08)");
glare.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = glare;
ctx.fillRect(mX, vY, vW, vH * 0.22);
ctx.beginPath();
ctx.moveTo(mX + vW * 0.05, vY);
ctx.lineTo(mX + vW * 0.30, vY);
ctx.lineTo(mX + vW * 0.18, vY + vH * 0.55);
ctx.lineTo(mX + vW * 0.00, vY + vH * 0.55);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.03)";
ctx.fill();
ctx.restore();
ctx.restore();
},
drawDial(ctx, cx, cy, radius, minAngle, maxAngle) {
const inkColor = "rgba(10, 15, 20, 0.85)";
const redColor = "#e63946";
const zeroAngle = minAngle + 0.8 * (maxAngle - minAngle);
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 1;
ctx.shadowOffsetX = 1;
ctx.lineWidth = 3.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(cx, cy, radius, Math.PI * 1.5 + minAngle, Math.PI * 1.5 + zeroAngle);
ctx.strokeStyle = inkColor; ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, radius, Math.PI * 1.5 + zeroAngle, Math.PI * 1.5 + maxAngle);
ctx.strokeStyle = redColor; ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, radius - 4, Math.PI * 1.5 + minAngle, Math.PI * 1.5 + zeroAngle);
ctx.strokeStyle = inkColor; ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, radius - 4, Math.PI * 1.5 + zeroAngle, Math.PI * 1.5 + maxAngle);
ctx.strokeStyle = redColor; ctx.stroke();
const ticks =[
{ label: '-20', pos: 0.0, isRed: false, major: true },
{ label: '10', pos: 0.25, isRed: false, major: true },
{ label: '7', pos: 0.4, isRed: false, major: true },
{ label: '5', pos: 0.52, isRed: false, major: true },
{ label: '', pos: 0.58, isRed: false, major: false },
{ label: '3', pos: 0.64, isRed: false, major: true },
{ label: '', pos: 0.69, isRed: false, major: false },
{ label: '1', pos: 0.74, isRed: false, major: true },
{ label: '0', pos: 0.8, isRed: false, major: true },
{ label: '1', pos: 0.86, isRed: true, major: true },
{ label: '', pos: 0.895, isRed: true, major: false },
{ label: '3', pos: 0.93, isRed: true, major: true },
{ label: '', pos: 0.965, isRed: true, major: false },
{ label: '+5', pos: 1.0, isRed: true, major: true }
];
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ticks.forEach(t => {
const angle = minAngle + t.pos * (maxAngle - minAngle);
const aRad = Math.PI * 1.5 + angle;
ctx.strokeStyle = t.isRed ? redColor : inkColor;
ctx.fillStyle = t.isRed ? redColor : inkColor;
ctx.lineWidth = t.major ? 2.5 : 1.5;
const innerR = radius - 4;
const outerR = t.major ? radius + 8 : radius + 3;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(aRad) * innerR, cy + Math.sin(aRad) * innerR);
ctx.lineTo(cx + Math.cos(aRad) * outerR, cy + Math.sin(aRad) * outerR);
ctx.stroke();
if (t.label) {
const fontSize = Math.max(9, radius * 0.12);
ctx.font = `bold ${fontSize}px "Arial Narrow", "Helvetica Neue", Arial, sans-serif`;
const textR = radius + (radius * 0.30);
ctx.fillText(t.label, cx + Math.cos(aRad) * textR, cy + Math.sin(aRad) * textR + (fontSize * 0.4));
}
});
const vuSize = Math.max(10, radius * 0.18);
ctx.font = `bold ${vuSize}px "Arial Narrow", Arial, sans-serif`;
ctx.fillStyle = inkColor;
ctx.fillText("VU", cx, cy - (radius * 0.25));
ctx.restore();
},
drawNeedle(ctx, cx, cy, length, angle) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.shadowColor = "rgba(0,0,0,0.60)";
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 1;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -length);
ctx.strokeStyle = "#ff3300"; ctx.lineWidth = 2.5; ctx.stroke();
ctx.shadowColor = "transparent";
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -length);
ctx.strokeStyle = "rgba(255,200,200,0.60)"; ctx.lineWidth = 0.9; ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, length * 0.08, 0, Math.PI * 2);
ctx.fillStyle = "#111"; ctx.fill();
ctx.restore();
},
renderLoop() {
if (!this.scaleCanvas || !this.isVisible) { this.rafId = null; return; }
const now = Date.now();
if (now - this.lastRenderTime < 16) {
this.rafId = requestAnimationFrame(() => this.renderLoop());
return;
}
this.lastRenderTime = now;
this.tryInitAudio();
let targetL = 0; let targetR = 0;
if (this.audioInitialized && this.audioCtx && this.isVuEnabled()) {
this.analyserL.getFloatTimeDomainData(this.dataL);
this.analyserR.getFloatTimeDomainData(this.dataR);
targetL = this.getLevel(this.dataL);
targetR = this.getLevel(this.dataR);
}
const attack = 0.35;
const decay = 0.08;
this.currentVuLeft += (targetL > this.currentVuLeft) ? (targetL - this.currentVuLeft) * attack : (targetL - this.currentVuLeft) * decay;
this.currentVuRight += (targetR > this.currentVuRight) ? (targetR - this.currentVuRight) * attack : (targetR - this.currentVuRight) * decay;
if (this.isVuEnabled() && this.vuCanvas) {
this.drawVuMeter(this.vuCanvas, this.currentVuLeft, this.currentVuRight);
}
if (Math.abs(this.targetStationScrollY - this.stationScrollY) > 0.5) {
this.stationScrollY += (this.targetStationScrollY - this.stationScrollY) * 0.15;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
if (this.animFreq !== null) {
if (!this.isDraggingScale) {
const diff = this.currentFreq - this.animFreq;
this.animFreq += Math.abs(diff) > 0.002 ? diff * this.SMOOTHING : diff;
}
this.drawScale(this.scaleCanvas, this.animFreq);
}
if (!this.isDraggingOuterKnob && Math.abs(this.outerVelocity) > 0.001) {
this.outerVelocity *= this.FRICTION;
this.applyKnobRotation(true, this.outerVelocity);
} else if (!this.isDraggingOuterKnob) {
this.outerVelocity = 0;
}
if (!this.isDraggingInnerKnob && Math.abs(this.innerVelocity) > 0.001) {
this.innerVelocity *= this.FRICTION;
this.applyKnobRotation(false, this.innerVelocity);
} else if (!this.isDraggingInnerKnob) {
this.innerVelocity = 0;
}
const currentKnobState = `${this.outerKnobAngle}_${this.innerKnobAngle}_${this.isFineTuningMode}`;
if (this.knobCanvas && this.lastKnobState !== currentKnobState) {
if (this.drawKnob(this.knobCanvas) !== false) {
this.lastKnobState = currentKnobState;
}
}
this.rafId = requestAnimationFrame(() => this.renderLoop());
},
addButton() {
if (!pluginConfig.ENABLE_ANALOG_SCALE) return;
if (this._btnAdding) return;
const BTN_ID = "et-analog-scale-btn";
if (document.getElementById(BTN_ID)) return;
this._btnAdding = true;
document.head.insertAdjacentHTML("beforeend", `<style>#${BTN_ID}:hover{filter:brightness(130%);} #${BTN_ID}.active{background-color:var(--color-2,#333)!important;}</style>`);
const tryAdd = (attempt = 0) => {
if (typeof addIconToPluginPanel === "function") {
try {
addIconToPluginPanel(BTN_ID, "Analog Scale", "solid", "ruler-horizontal", `Retro Design Elements V1.2 ET<br>By Highpoint`);
setTimeout(() => { const btn = document.getElementById(BTN_ID); if (btn) btn.classList.add("hide-phone"); }, 50);
} catch(e) {}
const btn = document.getElementById(BTN_ID);
if(btn) btn.addEventListener("click", () => this.toggle());
this._btnAdding = false;
return;
}
if (attempt < 60) setTimeout(() => tryAdd(attempt + 1), 500);
else this._btnAdding = false;
};
tryAdd();
},
removeButton() {
const btn = document.getElementById("et-analog-scale-btn");
if (btn) btn.remove();
this._btnAdding = false;
},
hookPS() {
const psElement = document.getElementById("data-ps");
const freqElement = document.getElementById("data-frequency");
if (!psElement || !freqElement) { setTimeout(() => this.hookPS(), 1000); return; }
let deleteTimer = null; let activeDeleteFreq = null;
let lastFreq = null; let freqSettledTime = Date.now();
let lastPS = null; let psSettledTime = Date.now();
const saveCurrentPS = () => {
if (this.currentFreq === null) return;
if (this.currentFreq !== lastFreq) { lastFreq = this.currentFreq; freqSettledTime = Date.now(); }
const ps = (psElement.innerText || psElement.textContent || "").replace(/\u00A0/g, ' ').trim();
if (ps !== lastPS) { lastPS = ps; psSettledTime = Date.now(); }
const freqStr = (Math.round(this.currentFreq * 10) / 10).toFixed(1);
let db = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
const isValidPS = ps.length >= 3 && ps !== freqStr && !ps.match(/^[.\-]+$/) && !ps.includes("MHz");
const isOnCenter = Math.abs(this.currentFreq - parseFloat(freqStr)) <= 0.015;
if (isValidPS) {
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; activeDeleteFreq = null; }
if (Date.now() - freqSettledTime < 800) {
return;
}
let shouldSave = true;
for (let key in db) {
if (db[key].ps === ps && key !== freqStr) {
if (Math.abs(parseFloat(key) - parseFloat(freqStr)) <= 0.2) { shouldSave = false; break; }
}
}
if (shouldSave) {
let entry = db[freqStr] || { ps: ps };
let isUpdated = false;
if (entry.ps !== ps) {
entry.ps = ps; isUpdated = true;['name','city','itu','erp','pol','dist','azi'].forEach(k => delete entry[k]);
}
if (entry.active !== true) { entry.active = true; isUpdated = true; }
if (Date.now() - freqSettledTime > 2000 && Date.now() - psSettledTime > 2000) {
const domFreq = parseFloat(freqElement.innerText);
if (Math.abs(domFreq - parseFloat(freqStr)) < 0.02) {
const getT = (id) => { const el = document.getElementById(id); return el ? el.innerText.trim() : ""; };
const meta = { name: 'data-station-name', city: 'data-station-city', itu: 'data-station-itu', erp: 'data-station-erp', pol: 'data-station-pol', dist: 'data-station-distance', azi: 'data-station-azimuth' };
for (let m in meta) {
let val = getT(meta[m]);
if (val && entry[m] !== val) { entry[m] = val; isUpdated = true; }
}
}
}
if (isUpdated || !db[freqStr]) {
entry.lastSeen = Date.now();
db[freqStr] = entry;
localStorage.setItem('retro_station_db', JSON.stringify(db));
if (this.isVisible && this.scaleCanvas) this.drawScale(this.scaleCanvas, this.currentFreq);
}
}
} else {
if (isOnCenter && db[freqStr]) {
if (activeDeleteFreq !== freqStr) {
clearTimeout(deleteTimer); activeDeleteFreq = freqStr;
deleteTimer = setTimeout(() => {
let currentDb = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
if (currentDb[freqStr]) {
delete currentDb[freqStr]; localStorage.setItem('retro_station_db', JSON.stringify(currentDb));
if (this.isVisible && this.scaleCanvas) this.drawScale(this.scaleCanvas, this.currentFreq);
}
activeDeleteFreq = null;
}, 3000);
}
} else {
if (deleteTimer) {
clearTimeout(deleteTimer); deleteTimer = null; activeDeleteFreq = null;
}
}
}
};
new MutationObserver(() => setTimeout(saveCurrentPS, 300)).observe(psElement, { childList: true, subtree: true, characterData: true });
new MutationObserver(saveCurrentPS).observe(freqElement, { childList: true, subtree: true, characterData: true });
setInterval(saveCurrentPS, 2000);
},
setupRetroTooltip() {
let tooltip = document.getElementById('retro-station-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'retro-station-tooltip';
tooltip.style.cssText = `position:fixed; background:rgba(15,25,30,0.95); color:#eee; padding:10px 14px; border-radius:6px; border:1px solid rgba(80,160,180,0.3); font-family:Arial,sans-serif; font-size:13px; z-index:90000; pointer-events:none; display:none; box-shadow:0 4px 12px rgba(0,0,0,0.5); backdrop-filter:blur(4px); min-width:150px;`;
document.body.appendChild(tooltip);
}
window.addEventListener('mousemove', (evt) => {
if (!this.isVisible || !this.scaleCanvas) return;
let hovered = null;
for (let st of this.renderedStations) {
if (evt.clientX >= this.scaleCanvas.getBoundingClientRect().left + st.left && evt.clientX <= this.scaleCanvas.getBoundingClientRect().left + st.right &&
evt.clientY >= this.scaleCanvas.getBoundingClientRect().top + st.top && evt.clientY <= this.scaleCanvas.getBoundingClientRect().top + st.bottom) {
hovered = st; break;
}
}
if (hovered) {
if (this.hoveredFreq !== hovered.f) {
this.hoveredFreq = hovered.f;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
const d = hovered.data;
const isSw = hovered.isSw;
const displayName = isSw ? d.name : (d.name || d.ps);
let freqStr = "";
if (this.currentUnit === 'kHz') {
freqStr = `${Math.round(hovered.f)} kHz`;
} else {
freqStr = `${hovered.f.toFixed(isSw ? 3 : 1)} MHz`;
}
let html = `<div style="font-size:16px; font-weight:bold; color:#fff; margin-bottom:4px;">${displayName} <span style="color:#3abf9a; font-size:14px; margin-left:6px;">${freqStr}</span></div>`;
let locStr = isSw ? (d.country ? `${d.location || ''}[${d.country}]` : d.location) : (d.city ? `${d.city} <span style="opacity:0.7">[${d.itu || '-'}]</span>` : '');
if (locStr) {
html += `<div style="margin-bottom:4px;">${locStr}</div>`;
}
let infos =[];
if (isSw) {
if (d.power) infos.push(`${d.power} kW`);
if (d.distance) infos.push(`${d.distance} km`);
if (d.azimuth) infos.push(`${d.azimuth}°`);
if (d.language) infos.push(`Lang: ${d.language}`);
} else {
if (d.erp) infos.push(`${d.erp} kW ${d.pol ? '['+d.pol+']' : ''}`);
if (d.dist) infos.push(`${d.dist} km`);
if (d.azi) infos.push(`${d.azi}°`);
}
if (infos.length) html += `<div style="font-size:11px; color:#aaa; margin-bottom:6px;">${infos.join(' &bull; ')}</div>`;
if (isSw) {
if (d.timeUTC) html += `<div style="font-size:11px; color:#88b; margin-bottom:2px;">UTC: ${d.timeUTC}</div>`;
if (d.source) html += `<div style="font-size:10px; color:#666;">Source: ${d.source}</div>`;
} else {
html += `<hr style="border:0; border-top:1px solid rgba(255,255,255,0.1); margin:6px 0;"><div style="font-size:11px; color:#77a;">Last seen: ${d.lastSeen ? new Date(d.lastSeen).toLocaleString() : 'Unknown'}</div>`;
}
tooltip.innerHTML = html; tooltip.style.display = 'block';
let tX = evt.clientX + 15, tY = evt.clientY + 15;
if (tX + tooltip.offsetWidth > window.innerWidth) tX = evt.clientX - tooltip.offsetWidth - 15;
tooltip.style.left = tX + 'px'; tooltip.style.top = tY + 'px';
} else {
tooltip.style.display = 'none';
if (this.hoveredFreq !== null) {
this.hoveredFreq = null;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
}
});
}
};
// =========================================================================
// MAGIC EYE ENGINE (Retro Vacuum Tube Indicator)
// =========================================================================
const MagicEyeEngine = {
audioInitialized: false,
audioCtx: null,
analyser: null,
dataArray: null,
currentLevel: 0,
canvasWrapper: null,
canvas: null,
lightCanvas: null,
isUserEnabled() {
return pluginConfig.ENABLE_MAGIC_EYE && localStorage.getItem('et_user_magic_eye') !== 'false';
},
toggleVisibility() {
if (this.canvasWrapper) {
this.canvasWrapper.style.display = this.isUserEnabled() ? 'block' : 'none';
}
},
init() {
if (!pluginConfig.ENABLE_MAGIC_EYE) return;
this.attemptMount();
},
attemptMount() {
const signalValueSpan = document.getElementById('data-signal');
if (!signalValueSpan) {
setTimeout(() => this.attemptMount(), 1000);
return;
}
const signalPanel = signalValueSpan.closest('.panel-33') || signalValueSpan.closest('div[class*="panel"]');
if (!signalPanel) return;
if (signalPanel.dataset.magicEyeInit === "true") return;
signalPanel.dataset.magicEyeInit = "true";
const heading = signalPanel.querySelector('h2.signal-heading') || signalPanel.querySelector('h2');
if (!heading) return;
const contentWrapper = document.createElement('div');
contentWrapper.className = 'magic-eye-content-wrapper';
Object.assign(contentWrapper.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
flexDirection: 'row',
position: 'relative'
});
const textWrapper = document.createElement('div');
textWrapper.className = 'magic-eye-text-wrapper';
Object.assign(textWrapper.style, {
flex: "1",
minWidth: "0",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center"
});
let currentElem = heading;
while (currentElem) {
const next = currentElem.nextElementSibling;
textWrapper.appendChild(currentElem);
currentElem = next;
}
this.canvasWrapper = document.createElement('div');
this.canvasWrapper.id = "magic-eye-wrapper";
Object.assign(this.canvasWrapper.style, {
width: "95px",
height: "95px",
flexShrink: "0",
position: "relative",
marginLeft: "15px"
});
if (!this.isUserEnabled()) {
this.canvasWrapper.style.display = 'none';
}
this.canvas = document.createElement('canvas');
this.canvas.id = 'magic-eye-canvas';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.canvasWrapper.appendChild(this.canvas);
contentWrapper.appendChild(this.canvasWrapper);
contentWrapper.appendChild(textWrapper);
signalPanel.appendChild(contentWrapper);
this.lightCanvas = document.createElement('canvas');
this.updateLoop();
},
tryInitAudio() {
if (this.audioInitialized) return;
if (typeof Stream !== 'undefined' && Stream?.Fallback?.Player?.Amplification) {
try {
let source = Stream.Fallback.Player.Amplification;
if (!source.context) return;
this.audioCtx = source.context;
this.analyser = this.audioCtx.createAnalyser();
this.analyser.fftSize = 256;
this.dataArray = new Float32Array(this.analyser.fftSize);
source.connect(this.analyser);
this.audioInitialized = true;
} catch(e) {}
}
},
lastEyeRenderTime: 0,
updateLoop() {
this.tryInitAudio();
const signalElem = document.getElementById('data-signal');
if (!signalElem) {
requestAnimationFrame(() => this.updateLoop());
return;
}
// FPS BREMS for Magic Eye
const now = Date.now();
if (now - this.lastEyeRenderTime < 33) {
requestAnimationFrame(() => this.updateLoop());
return;
}
this.lastEyeRenderTime = now;
const signalText = signalElem.textContent;
let textVal = parseFloat(signalText);
let targetBaseLevel = 0;
if (!isNaN(textVal)) {
let ssu = 'dbf';
const panelText = signalElem.parentElement ? signalElem.parentElement.textContent.toLowerCase() : '';
if (panelText.includes('dbm')) ssu = 'dbm';
else if (panelText.includes('dbuv') || panelText.includes('dbµv') || panelText.includes('dbμv')) ssu = 'dbuv';
let resultSensitivity;
if (ssu === 'dbuv' || ssu === 'dbµv' || ssu === 'dbμv') resultSensitivity = Math.round(textVal + 10.875);
else if (ssu === 'dbm') resultSensitivity = Math.round(textVal + 119.75);
else resultSensitivity = Math.round(textVal);
let clampedVal = Math.max(0.0, resultSensitivity);
targetBaseLevel = Math.min(1.0, clampedVal / 100.0);
}
let audioPulse = 0;
if (this.audioInitialized && this.audioCtx.state === 'running') {
this.analyser.getFloatTimeDomainData(this.dataArray);
let sum = 0;
for (let i = 0; i < this.dataArray.length; i++) { sum += this.dataArray[i] * this.dataArray[i]; }
let rmsLevel = Math.sqrt(sum / this.dataArray.length);
audioPulse = Math.min(1.0, rmsLevel * 10.0);
}
const targetCombinedLevel = (targetBaseLevel * 0.9) + (audioPulse * 0.1);
this.currentLevel += (targetCombinedLevel - this.currentLevel) * 0.12;
const dpr = window.devicePixelRatio || 1;
const w = this.canvasWrapper.offsetWidth;
const h = this.canvasWrapper.offsetHeight;
if (w > 0 && h > 0) {
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.lightCanvas.width = this.canvas.width;
this.lightCanvas.height = this.canvas.height;
const ctx = this.canvas.getContext('2d');
const lCtx = this.lightCanvas.getContext('2d');
ctx.scale(dpr, dpr);
lCtx.scale(dpr, dpr);
this.drawExtremeGlowTube(ctx, lCtx, w, h, this.currentLevel);
}
requestAnimationFrame(() => this.updateLoop());
},
drawExtremeGlowTube(ctx, lCtx, w, h, level) {
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 2;
const innerR = r * 0.88;
const capR = innerR * 0.45;
lCtx.clearRect(0, 0, w, h);
const glowGrad = lCtx.createRadialGradient(cx, cy, 0, cx, cy, innerR);
glowGrad.addColorStop(0.00, "rgba(0, 40, 10, 0.4)");
glowGrad.addColorStop(0.35, "rgba(0, 180, 30, 1.0)");
glowGrad.addColorStop(0.65, "rgba(0, 255, 40, 1.0)");
glowGrad.addColorStop(0.90, "rgba(30, 255, 60, 1.0)");
glowGrad.addColorStop(1.00, "rgba(0, 20, 5, 0.0)");
lCtx.fillStyle = glowGrad;
lCtx.beginPath();
lCtx.arc(cx, cy, innerR, 0, Math.PI * 2);
lCtx.fill();
lCtx.save();
lCtx.globalCompositeOperation = "destination-out";
lCtx.lineWidth = 3.5;
for(let a = 0; a < Math.PI * 2; a += Math.PI / 2) {
lCtx.beginPath();
lCtx.moveTo(cx + Math.cos(a) * capR, cy + Math.sin(a) * capR);
lCtx.lineTo(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR);
lCtx.strokeStyle = "rgba(0, 0, 0, 0.55)";
lCtx.stroke();
}
lCtx.restore();
lCtx.save();
lCtx.globalCompositeOperation = "source-atop";
lCtx.lineWidth = 1.5;
let j = 0;
for (let rCircle = capR + 1; rCircle <= innerR; rCircle += 3.5) {
lCtx.beginPath();
lCtx.arc(cx, cy, rCircle, 0, Math.PI * 2);
lCtx.strokeStyle = (j % 2 === 0) ? "rgba(0, 40, 5, 0.06)" : "rgba(150, 255, 150, 0.04)";
lCtx.stroke();
j++;
}
lCtx.restore();
lCtx.globalCompositeOperation = "destination-out";
const maxShadowAngle = Math.PI;
const minShadowAngle = Math.PI * 0.05;
const currentShadowAngle = maxShadowAngle - (level * (maxShadowAngle - minShadowAngle));
lCtx.fillStyle = "black";
lCtx.shadowColor = "black";
lCtx.shadowBlur = innerR * 0.15;
const curveFactor = level * 0.65;
const drawCurvedShadow = (midAngle) => {
const startAngle = midAngle - currentShadowAngle / 2;
const endAngle = midAngle + currentShadowAngle / 2;
const R_out = innerR * 1.5;
lCtx.beginPath();
lCtx.moveTo(cx + Math.cos(startAngle) * capR, cy + Math.sin(startAngle) * capR);
let cp1Angle = startAngle + (midAngle - startAngle) * curveFactor;
let cpR = capR + (R_out - capR) * 0.38;
lCtx.quadraticCurveTo(
cx + Math.cos(cp1Angle) * cpR, cy + Math.sin(cp1Angle) * cpR,
cx + Math.cos(startAngle) * R_out, cy + Math.sin(startAngle) * R_out
);
lCtx.arc(cx, cy, R_out, startAngle, endAngle);
let cp2Angle = endAngle - (endAngle - midAngle) * curveFactor;
lCtx.quadraticCurveTo(
cx + Math.cos(cp2Angle) * cpR, cy + Math.sin(cp2Angle) * cpR,
cx + Math.cos(endAngle) * capR, cy + Math.sin(endAngle) * capR
);
lCtx.fill();
};
drawCurvedShadow(0);
drawCurvedShadow(Math.PI);
lCtx.shadowBlur = 0;
lCtx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.85)";
ctx.shadowBlur = 8;
ctx.shadowOffsetY = 4;
ctx.shadowOffsetX = 2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
const glassRimGrad = ctx.createLinearGradient(0, cy - r, 0, cy + r);
glassRimGrad.addColorStop(0, "rgba(200, 200, 200, 0.80)");
glassRimGrad.addColorStop(0.15, "rgba(100, 100, 100, 0.7)");
glassRimGrad.addColorStop(0.5, "rgba(30, 30, 30, 0.5)");
glassRimGrad.addColorStop(0.85, "rgba(80, 80, 80, 0.7)");
glassRimGrad.addColorStop(1, "rgba(180, 180, 180, 0.7)");
ctx.fillStyle = glassRimGrad;
ctx.fill();
ctx.restore();
ctx.beginPath(); ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)"; ctx.lineWidth = 1; ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, innerR + 1, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"; ctx.lineWidth = 2.0; ctx.stroke();
ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, innerR, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = "#010401"; ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.lineWidth = 1.5; ctx.shadowColor = "rgba(0,0,0,0.4)"; ctx.shadowBlur = 4;
let i = 0;
for (let rLine = capR + 3; rLine < innerR; rLine += 7.0) {
ctx.beginPath(); ctx.arc(cx, cy, rLine, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(0, 0, 0, ${0.02 + (i % 3) * 0.01})`;
ctx.stroke(); i++;
}
ctx.restore();
const dpr = window.devicePixelRatio || 1;
ctx.globalCompositeOperation = "screen";
ctx.shadowColor = "rgba(0, 255, 50, 0.8)"; ctx.shadowBlur = 15;
ctx.drawImage(this.lightCanvas, 0, 0, w * dpr, h * dpr, 0, 0, w, h);
ctx.globalCompositeOperation = "source-over"; ctx.shadowBlur = 0;
ctx.drawImage(this.lightCanvas, 0, 0, w * dpr, h * dpr, 0, 0, w, h);
const funnelGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, innerR);
funnelGrad.addColorStop(0.00, "rgba(0, 0, 0, 0.95)");
funnelGrad.addColorStop(0.25, "rgba(0, 0, 0, 0.70)");
funnelGrad.addColorStop(0.50, "rgba(0, 0, 0, 0.05)");
funnelGrad.addColorStop(0.82, "rgba(0, 0, 0, 0.00)");
funnelGrad.addColorStop(0.90, "rgba(200, 255, 200, 0.08)");
funnelGrad.addColorStop(0.96, "rgba(0, 0, 0, 0.60)");
funnelGrad.addColorStop(1.00, "rgba(0, 0, 0, 1.00)");
ctx.fillStyle = funnelGrad; ctx.fillRect(0, 0, w, h);
ctx.restore();
ctx.beginPath(); ctx.arc(cx, cy, capR, 0, Math.PI * 2);
const capGrad = ctx.createLinearGradient(cx - capR, cy - capR, cx + capR, cy + capR);
capGrad.addColorStop(0, "#222"); capGrad.addColorStop(0.5, "#0f0f0f"); capGrad.addColorStop(1, "#000");
ctx.fillStyle = capGrad;
ctx.shadowColor = "rgba(0,0,0,0.95)"; ctx.shadowBlur = innerR * 0.4;
ctx.shadowOffsetY = 0; ctx.shadowOffsetX = 0; ctx.fill(); ctx.shadowBlur = 0;
ctx.lineWidth = 1; ctx.strokeStyle = "rgba(255, 255, 255, 0.05)"; ctx.stroke();
ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, innerR, 0, Math.PI * 2); ctx.clip();
const glassGlare = ctx.createRadialGradient(cx, cy - innerR * 0.5, 0, cx, cy, innerR * 1.1);
glassGlare.addColorStop(0, "rgba(255,255,255,0.18)");
glassGlare.addColorStop(0.4, "rgba(255,255,255,0.03)");
glassGlare.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = glassGlare;
ctx.beginPath(); ctx.arc(cx, cy, innerR, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
};
// =========================================================================
// WEBSOCKET INTERCEPTOR
// =========================================================================
const originalWebSocketSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
if (typeof data === 'string') {
if (pluginConfig.ENABLE_AM_SCANNER && data.startsWith('{')) {
try {
const parsed = JSON.parse(data);
if (parsed.type === "Scanner" && parsed.value && parsed.value.status === "command") {
const cmd = parsed.value;
const freq = getGlobalCurrentFrequencyInMHz();
if (freq < 30.0) {
if ('Scan' in cmd) {
if (cmd.Scan === 'on') _startLocalScanner('scan', 'up');
else _stopLocalScanner();
}
if ('Search' in cmd) {
_startLocalScanner('search', cmd.Search);
}
return;
}
}
} catch(e) {}
}
if (pluginConfig && pluginConfig.overrideServerTuningLimit && /^T\d+$/.test(data)) {
if (isLocalScannerRunning && !isInternalScannerTuning) _stopLocalScanner();
let freqMhz = parseInt(data.substring(1), 10) / 1000;
if (freqMhz > 0) {
const isFm = freqMhz >= 64.0;
let modified = false;
if (isFm) {
if (freqMhz < pluginConfig.fmLower) { freqMhz = pluginConfig.fmLower; modified = true; }
else if (freqMhz > pluginConfig.fmUpper) { freqMhz = pluginConfig.fmUpper; modified = true; }
} else {
if (freqMhz < pluginConfig.amLower) { freqMhz = pluginConfig.amLower; modified = true; }
else if (freqMhz > pluginConfig.amUpper) { freqMhz = pluginConfig.amUpper; modified = true; }
}
if (modified) {
const currentFreq = getGlobalCurrentFrequencyInMHz();
if (Math.abs(currentFreq - freqMhz) < 0.0001) return;
data = "T" + Math.round(freqMhz * 1000);
}
}
}
}
originalWebSocketSend.apply(this, arguments);
};
const fetchPluginConfig = async () => {
try {
const res = await fetch('/enhanced_tuning/api/auth-check');
if (res.ok) {
const data = await res.json();
pluginConfig = { ...pluginConfig, ...data.config };
isAdmin = data.isAdmin;
}
} catch (err) {
console.warn("[Enhanced Tuning] API Fetch feilet. Bruker innebygd standard config.", err);
}
};
const injectSettingsIcon = () => {
if (!isAdmin) return;
const observer = new MutationObserver(() => {
const fmBtn = document.querySelector('.band-selector-button[data-band-key="FM"], .band-selector-button[data-band-name="FM"]');
const freqContainerForFallback = pluginConfig.HIDE_ALL_BUTTONS ? document.getElementById('freq-container') : null;
const anchor = fmBtn || freqContainerForFallback;
if (anchor && !document.getElementById('et-admin-btn')) {
const parentContainer = fmBtn ? fmBtn.closest('.side-band-button-container, .main-bands-wrapper') : freqContainerForFallback;
if (parentContainer) {
parentContainer.style.position = 'relative';
const freqContainer = document.getElementById('freq-container');
if (freqContainer && pluginConfig.LAYOUT_STYLE === 'classic') {
freqContainer.style.overflow = 'visible';
}
}
const settingsBtn = document.createElement('button');
settingsBtn.id = 'et-admin-btn';
settingsBtn.innerHTML = '⚙️';
settingsBtn.title = 'Enhanced Tuning Settings';
settingsBtn.style.cssText = fmBtn ? `
position: absolute;
top: 0px;
left: ${pluginConfig.LAYOUT_STYLE === 'modern' ? '-45px' : '-28px'};
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
padding: 0; /* Fjerner all ekstra plass inni knappen */
width: 20px; /* Fast, liten bredde */
height: 20px; /* Fast, liten høyde */
transition: transform 0.2s, text-shadow 0.2s;
color: var(--color-text);
z-index: 1000;
` : `
position: absolute;
top: 5px;
right: 5px;
background: transparent;
border: none;
cursor: pointer;
font-size: 18px;
padding: 0;
width: 20px;
height: 20px;
transition: transform 0.2s, text-shadow 0.2s;
color: var(--color-text);
z-index: 1000;
`;
settingsBtn.onmouseover = () => {
settingsBtn.style.transform = 'scale(1.2) rotate(45deg)';
settingsBtn.style.textShadow = '0 0 5px rgba(255,255,255,0.5)';
};
settingsBtn.onmouseout = () => {
settingsBtn.style.transform = 'scale(1) rotate(0deg)';
settingsBtn.style.textShadow = 'none';
};
settingsBtn.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
window.open('/enhanced_tuning/AP', '_blank');
};
if (parentContainer) parentContainer.appendChild(settingsBtn);
else anchor.parentNode.insertBefore(settingsBtn, anchor);
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
const checkForGradientPluginAndApplyStyles = () => {
let attempts = 0;
const maxAttempts = 40;
const isGradientPluginActive = () => {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.selectorText && rule.selectorText.includes('.playbutton') && rule.style.backgroundImage) {
if (rule.style.backgroundImage.includes('linear-gradient')) return true;
}
}
} catch (e) { continue; }
}
return false;
};
const styleCheckInterval = setInterval(() => {
attempts++;
if (isGradientPluginActive()) {
clearInterval(styleCheckInterval);
const styleElement = document.createElement('style');
styleElement.textContent = `
.band-selector-button, .am-view-button, .sw-grid-button, .loop-toggle-button {
background-image: linear-gradient(var(--color-4), var(--color-3));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; background-color: transparent; opacity: 0.6;
}
.band-selector-button.active-band, .am-view-button.active-band, .sw-grid-button.active-band, .loop-toggle-button.active { opacity: 1; }
.band-selector-button:hover, .am-view-button:hover, .sw-grid-button:hover, .loop-toggle-button:hover {
background-image: linear-gradient(var(--color-3), var(--color-5));
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); transform: translateY(0.1px); opacity: 1;
}
`;
document.head.appendChild(styleElement);
} else if (attempts >= maxAttempts) {
clearInterval(styleCheckInterval);
}
}, 250);
};
const initAdminPanel = () => {
const globalLowerInput = document.getElementById('webserver-tuningLowerLimit');
const globalUpperInput = document.getElementById('webserver-tuningUpperLimit');
const origSwitchInput = document.getElementById('webserver-tuningLimit');
const saveBtn = document.getElementById('submit-config');
if (!globalLowerInput || !globalUpperInput || !pluginConfig) return;
if (!pluginConfig.overrideServerTuningLimit) return;
const parentDiv = globalLowerInput.parentNode;
if (!parentDiv) return;
const origElementsToHide = [
globalLowerInput,
document.querySelector('label[for="webserver-tuningLowerLimit"]'),
globalUpperInput,
document.querySelector('label[for="webserver-tuningUpperLimit"]'),
origSwitchInput ? origSwitchInput.closest('.switch') : null,
document.querySelector('label[for="webserver-tuningLimit"]')
];
origElementsToHide.forEach(el => {
if (el) {
el.style.display = 'none';
if (el.nextElementSibling && el.nextElementSibling.tagName === 'BR') el.nextElementSibling.style.display = 'none';
}
});
if (origSwitchInput && !origSwitchInput.checked) {
origSwitchInput.checked = true;
origSwitchInput.dispatchEvent(new Event('change', { bubbles: true }));
}
let origLow = parseFloat(globalLowerInput.value);
let origHigh = parseFloat(globalUpperInput.value);
let fmL = pluginConfig.fmLower;
let fmU = pluginConfig.fmUpper;
let amL = pluginConfig.amLower;
let amU = pluginConfig.amUpper;
const pluginExpectedMin = Math.min(fmL, amL);
const pluginExpectedMax = Math.max(fmU, amU);
if (!isNaN(origLow) && !isNaN(origHigh)) {
if (Math.abs(origLow - pluginExpectedMin) > 0.001 || Math.abs(origHigh - pluginExpectedMax) > 0.001) {
fmL = origLow;
amL = origLow;
fmU = origHigh;
amU = origHigh;
}
}
const etInputsContainer = document.createElement('div');
etInputsContainer.id = 'et-enhanced-inputs';
etInputsContainer.innerHTML = `
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; text-transform: uppercase; color: var(--color-5); font-size: 13px;">Enhanced Tuning Limits</span>
<span style="font-size:12px; color:#aaa; display:block; margin-top:2px;">(Override toggled ON via Enhanced Settings Panel)</span>
</div>
<div style="display: flex; justify-content: flex-start; gap: 15px; flex-wrap: wrap; margin-bottom: 10px;">
<div>
<label for="et-fm-lower" style="display: block; margin-bottom: 2px; font-weight: bold; font-size: 12px;">Lower FM limit (MHz)</label>
<input type="text" id="et-fm-lower" value="${fmL}" class="input-text w-100 br-15" style="margin-top: 0; min-height: 40px; height: 40px;">
</div>
<div>
<label for="et-fm-upper" style="display: block; margin-bottom: 2px; font-weight: bold; font-size: 12px;">Upper FM limit (MHz)</label>
<input type="text" id="et-fm-upper" value="${fmU}" class="input-text w-100 br-15" style="margin-top: 0; min-height: 40px; height: 40px;">
</div>
</div>
<div style="display: flex; justify-content: flex-start; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
<div>
<label for="et-am-lower" style="display: block; margin-bottom: 2px; font-weight: bold; font-size: 12px;">Lower AM limit (MHz)</label>
<input type="text" id="et-am-lower" value="${amL}" class="input-text w-100 br-15" style="margin-top: 0; min-height: 40px; height: 40px;">
</div>
<div>
<label for="et-am-upper" style="display: block; margin-bottom: 2px; font-weight: bold; font-size: 12px;">Upper AM limit (MHz)</label>
<input type="text" id="et-am-upper" value="${amU}" class="input-text w-100 br-15" style="margin-top: 0; min-height: 40px; height: 40px;">
</div>
</div>
`;
parentDiv.insertBefore(etInputsContainer, globalLowerInput);
const syncGlobalLimits = () => {
const ui_fmL = parseFloat(document.getElementById('et-fm-lower').value) || 64.0;
const ui_amL = parseFloat(document.getElementById('et-am-lower').value) || 0.144;
const ui_fmU = parseFloat(document.getElementById('et-fm-upper').value) || 108.0;
const ui_amU = parseFloat(document.getElementById('et-am-upper').value) || 30.0;
const minLim = Math.min(ui_fmL, ui_amL);
const maxLim = Math.max(ui_fmU, ui_amU);
globalLowerInput.value = minLim;
globalUpperInput.value = maxLim;
globalLowerInput.dispatchEvent(new Event('input', { bubbles: true }));
globalUpperInput.dispatchEvent(new Event('input', { bubbles: true }));
};['et-fm-lower', 'et-am-lower', 'et-fm-upper', 'et-am-upper'].forEach(id => {
document.getElementById(id).addEventListener('input', syncGlobalLimits);
});
syncGlobalLimits();
if (saveBtn) {
saveBtn.addEventListener('click', () => {
syncGlobalLimits();
const updatedLimits = {
fmLower: parseFloat(document.getElementById('et-fm-lower').value),
fmUpper: parseFloat(document.getElementById('et-fm-upper').value),
amLower: parseFloat(document.getElementById('et-am-lower').value),
amUpper: parseFloat(document.getElementById('et-am-upper').value),
};
fetch('/enhanced_tuning/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedLimits)
}).catch(err => console.error("[Enhanced Tuning] Error saving limits", err));
});
}
};
const injectUserSettingsUI = () => {
const modalContent = document.querySelector('.modal-panel-content');
if (!modalContent) {
setTimeout(injectUserSettingsUI, 500);
return;
}
const imperialInput = document.getElementById('imperial-units');
if (!imperialInput || document.getElementById('et-user-settings-wrapper')) return;
let baseWrapper = imperialInput;
while (baseWrapper.parentElement && !baseWrapper.parentElement.classList.contains('auto')) {
baseWrapper = baseWrapper.parentElement;
}
const container = document.createElement('div');
container.id = 'et-user-settings-wrapper';
container.style.marginTop = '15px';
container.style.marginBottom = '15px';
container.style.borderTop = '1px solid rgba(255,255,255,0.1)';
container.style.paddingTop = '15px';
function addToggle(id, labelText, checkedState, onChange) {
const clone = baseWrapper.cloneNode(true);
const input = clone.querySelector('input');
if (input) {
input.id = id;
input.checked = checkedState;
input.addEventListener('change', onChange);
}
function replaceText(node) {
if (node.nodeType === 3 && /imperial units/i.test(node.nodeValue)) {
node.nodeValue = node.nodeValue.replace(/imperial units/i, labelText);
} else {
if (node.tagName === 'LABEL' && node.getAttribute('for') === 'imperial-units') {
node.setAttribute('for', id);
}
node.childNodes.forEach(replaceText);
}
}
replaceText(clone);
container.appendChild(clone);
}
if (pluginConfig.ENABLE_VU_METER) {
let isVuEnabled = localStorage.getItem('et_user_vu_enabled') !== 'false';
addToggle('et-vu-toggle', 'SHOW VU METER', isVuEnabled, (e) => {
localStorage.setItem('et_user_vu_enabled', e.target.checked);
AnalogScaleEngine.applyScaleLayout();
});
}
if (pluginConfig.ENABLE_MAGIC_EYE) {
let isMagicEyeEnabled = localStorage.getItem('et_user_magic_eye') !== 'false';
addToggle('et-magic-eye-toggle', 'SHOW MAGIC EYE', isMagicEyeEnabled, (e) => {
localStorage.setItem('et_user_magic_eye', e.target.checked);
MagicEyeEngine.toggleVisibility();
});
}
if (pluginConfig.ENABLE_PS_SCALE) {
let isPsEnabled = localStorage.getItem('et_user_ps_scale') !== 'false';
addToggle('et-ps-toggle', 'SHOW PS STATIONS', isPsEnabled, (e) => {
localStorage.setItem('et_user_ps_scale', e.target.checked);
if (AnalogScaleEngine.scaleCanvas) AnalogScaleEngine.drawScale(AnalogScaleEngine.scaleCanvas, AnalogScaleEngine.animFreq);
});
}
if (pluginConfig.ENABLE_SPECTRUM_OVERLAY) {
let isSpecEnabled = localStorage.getItem('et_user_spectrum_overlay') !== 'false';
addToggle('et-spec-toggle', 'SHOW SPECTRUM', isSpecEnabled, (e) => {
localStorage.setItem('et_user_spectrum_overlay', e.target.checked);
if (AnalogScaleEngine.scaleCanvas) AnalogScaleEngine.drawScale(AnalogScaleEngine.scaleCanvas, AnalogScaleEngine.animFreq);
});
}
if (pluginConfig.ENABLE_SW_STATIONS_SCALE) {
let isSwScaleEnabled = localStorage.getItem('et_user_sw_scale') !== 'false';
addToggle('et-sw-scale-toggle', 'SHOW SW STATIONS', isSwScaleEnabled, (e) => {
localStorage.setItem('et_user_sw_scale', e.target.checked);
if (e.target.checked && AnalogScaleEngine.currentKey && AnalogScaleEngine.currentKey.endsWith('m')) {
AnalogScaleEngine.fetchSwStations(AnalogScaleEngine.currentMin, AnalogScaleEngine.currentMax);
} else if (AnalogScaleEngine.scaleCanvas) {
AnalogScaleEngine.drawScale(AnalogScaleEngine.scaleCanvas, AnalogScaleEngine.animFreq);
}
});
}
if (pluginConfig.ENABLE_ANALOG_SCALE) {
let currentBrightness = parseFloat(localStorage.getItem('et_user_scale_brightness')) || pluginConfig.ANALOG_SCALE_BRIGHTNESS;
const sliderDiv = document.createElement('div');
sliderDiv.className = 'panel-full flex-center no-bg m-0';
sliderDiv.style.flexDirection = 'column';
sliderDiv.style.marginTop = '15px';
sliderDiv.innerHTML = `
<span class="text-bold" style="color: var(--color-4); text-transform: uppercase; font-size: 13px; margin-bottom: 5px;">FM Scale Brightness</span>
<div style="width: 200px;">
<input type="range" id="et-analog-scale-brightness" min="0.2" max="1.8" step="0.05" value="${currentBrightness}" style="width: 100%; cursor: pointer; margin: 0;">
</div>
`;
const slider = sliderDiv.querySelector('input');
slider.addEventListener('input', (e) => {
localStorage.setItem('et_user_scale_brightness', parseFloat(e.target.value));
AnalogScaleEngine.lastKnobState = "";
});
container.appendChild(sliderDiv);
}
if (container.childNodes.length > 0) {
baseWrapper.parentNode.insertBefore(container, baseWrapper.nextSibling);
}
};
const initializePlugin = () => {
try {
const enabledBandsList = Array.isArray(pluginConfig.ENABLED_BANDS) ? pluginConfig.ENABLED_BANDS : ['FM', 'OIRT', 'SW', 'MW', 'LW'];
checkForGradientPluginAndApplyStyles();
injectSettingsIcon();
injectUserSettingsUI();
// INIT ANALOG SCALE
AnalogScaleEngine.init();
MagicEyeEngine.init();
if (!pluginConfig.ENABLE_AM_BW && (typeof console !== 'undefined')) {
if (pluginConfig.ENABLE_DEFAULT_AM_BW) console.warn('[BandSelector] ENABLE_DEFAULT_AM_BW set but ENABLE_AM_BW is false.');
}
const dataFrequencyElement = document.getElementById('data-frequency');
if(!dataFrequencyElement) return;
const getCurrentFrequencyInMHz = () => {
const freqText = dataFrequencyElement.textContent;
let freqValue = parseFloat(freqText);
if (freqText.toLowerCase().includes('khz')) freqValue /= 1000;
return freqValue;
};
// =========================================================================
// HIDE DECIMAL POINT FOR HF BAND, AND DIM LEADING ZERO BELOW 1 MHz
// =========================================================================
const findFreqCharNode = (targetIndex) => {
let pos = 0, child = dataFrequencyElement.firstChild;
while (child) {
const len = child.textContent.length;
if (targetIndex >= pos && targetIndex < pos + len) return { node: child, pos, len };
pos += len;
child = child.nextSibling;
}
return null;
};
const applyFreqDecimalHiding = () => {
const raw = dataFrequencyElement.textContent;
if (!raw || raw.toLowerCase().includes('khz') || !raw.includes('.')) return;
const value = parseFloat(raw);
if (isNaN(value)) return;
const shouldHide = pluginConfig.HIDE_DECIMAL_FOR_HF && value <= pluginConfig.HIDE_DECIMAL_HF_THRESHOLD;
const shouldDimZero = shouldHide && value < 1 && raw.charAt(0) === '0' && raw.charAt(1) === '.';
const dotIndex = raw.indexOf('.');
const dotInfo = findFreqCharNode(dotIndex);
if (dotInfo) {
const { node: child, pos, len } = dotInfo;
if (child.nodeType === Node.TEXT_NODE) {
if (shouldHide) {
const text = child.textContent;
const localIdx = dotIndex - pos;
const frag = document.createDocumentFragment();
if (localIdx > 0) frag.appendChild(document.createTextNode(text.slice(0, localIdx)));
const span = document.createElement('span');
span.className = 'et-hidden-dot';
span.dataset.etHiddenByDecimal = '1';
span.style.display = 'none';
span.textContent = '.';
frag.appendChild(span);
if (localIdx + 1 < text.length) frag.appendChild(document.createTextNode(text.slice(localIdx + 1)));
dataFrequencyElement.replaceChild(frag, child);
}
} else if (child.nodeType === Node.ELEMENT_NODE && len === 1) {
if (shouldHide && child.style.display !== 'none') {
child.dataset.etHiddenByDecimal = '1';
child.style.display = 'none';
} else if (!shouldHide && child.dataset.etHiddenByDecimal === '1') {
child.style.display = '';
delete child.dataset.etHiddenByDecimal;
}
}
}
const zeroInfo = findFreqCharNode(0);
if (zeroInfo) {
const { node: child, len } = zeroInfo;
if (child.nodeType === Node.TEXT_NODE) {
if (shouldDimZero) {
const text = child.textContent;
const frag = document.createDocumentFragment();
const span = document.createElement('span');
span.className = 'et-dimmed-zero';
span.textContent = text.charAt(0);
frag.appendChild(span);
if (text.length > 1) frag.appendChild(document.createTextNode(text.slice(1)));
dataFrequencyElement.replaceChild(frag, child);
}
} else if (child.nodeType === Node.ELEMENT_NODE && len === 1) {
if (shouldDimZero && !child.classList.contains('et-dimmed-zero')) {
child.classList.add('et-dimmed-zero');
} else if (!shouldDimZero && child.classList.contains('et-dimmed-zero')) {
child.classList.remove('et-dimmed-zero');
}
}
}
};
const freqDecimalObserver = new MutationObserver(applyFreqDecimalHiding);
freqDecimalObserver.observe(dataFrequencyElement, { childList: true, characterData: true, subtree: true });
applyFreqDecimalHiding();
// =========================================================================
// SMART kHZ INPUT INTERCEPTOR
// =========================================================================
if (pluginConfig.ENABLE_SMART_KHZ_INPUT) {
setTimeout(() => {
const oldInput = document.getElementById('commandinput');
if (oldInput && !oldInput.dataset.smartKhz) {
const newInput = oldInput.cloneNode(true);
newInput.dataset.smartKhz = "true";
oldInput.parentNode.replaceChild(newInput, oldInput);
newInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
let valStr = newInput.value.trim();
let val = parseFloat(valStr);
if (!isNaN(val)) {
const currentFreq = getGlobalCurrentFrequencyInMHz();
let targetMhz = val;
if (currentFreq < 30.0) {
if (/^\d+$/.test(valStr) && val >= 130) targetMhz = val / 1000;
}
else {
if (/^\d+$/.test(valStr)) {
if (val >= 640 && val <= 1080) targetMhz = val / 10;
else if (val >= 6400 && val <= 10800) targetMhz = val / 100;
}
}
if (typeof socket !== 'undefined' && socket && socket.readyState === WebSocket.OPEN) {
socket.send("T" + Math.round(targetMhz * 1000));
}
newInput.value = '';
newInput.focus();
}
}
});
}
}, 1000);
}
const FM_DX_TUNER_BW_OPTIONS = { '3 kHz': 'W3000', '4 kHz': 'W4000', '6 kHz': 'W6000', '8 kHz': 'W8000' };
const AM_BW_MAPPING = { '56000': 3, '64000': 4, '72000': 6, '84000': 8 };
const ALL_BANDS = {
'AM_SUPER': { name: 'AM', tune: 1.000, start: 0.144, end: 27.0, displayUnit: 'MHz' },
'FM': { name: 'FM', tune: 87.500, start: 87.5, end: 108.0, displayUnit: 'MHz' },
'OIRT': { name: 'OIRT', tune: 65.900, start: 65.9, end: 74.0, displayUnit: 'MHz' },
'SW': { name: 'SW', tune: 9.400, start: 1.710, end: 27.0, displayUnit: 'MHz' },
'MW': { name: 'MW', tune: 0.504, start: 0.504, end: 1.701, displayUnit: 'kHz' },
'LW': { name: 'LW', tune: 0.144, start: 0.144, end: 0.351, displayUnit: 'kHz' },
};
switch (pluginConfig.TUNING_STANDARD) {
case 'americas':
ALL_BANDS['MW'] = { name: 'MW', tune: 0.530, start: 0.530, end: 1.700, displayUnit: 'kHz' };
ALL_BANDS['FM'] = { name: 'FM', tune: 87.500, start: 87.5, end: 107.9, displayUnit: 'MHz' };
break;
case 'japan':
ALL_BANDS['FM'] = { name: 'FM', tune: 76.000, start: 76.0, end: 95.0, displayUnit: 'MHz' };
break;
}
if (pluginConfig.customMainBands) {
for (const key in pluginConfig.customMainBands) {
if (ALL_BANDS[key] && pluginConfig.customMainBands[key]) {
if (pluginConfig.customMainBands[key].name) ALL_BANDS[key].name = pluginConfig.customMainBands[key].name;
if (pluginConfig.customMainBands[key].tune !== undefined && !isNaN(pluginConfig.customMainBands[key].tune)) ALL_BANDS[key].tune = pluginConfig.customMainBands[key].tune;
if (pluginConfig.customMainBands[key].start !== undefined) ALL_BANDS[key].start = pluginConfig.customMainBands[key].start;
if (pluginConfig.customMainBands[key].end !== undefined) ALL_BANDS[key].end = pluginConfig.customMainBands[key].end;
}
}
}
const SW_BANDS = {
'160m': { tune: 1.8, start: 1.8, end: 2.0, displayUnit: 'MHz' }, '120m': { tune: 2.3, start: 2.3, end: 2.5, displayUnit: 'MHz' },
'90m': { tune: 3.2, start: 3.2, end: 3.4, displayUnit: 'MHz' }, '75m': { tune: 3.9, start: 3.9, end: 4.0, displayUnit: 'MHz' },
'60m': { tune: 4.75,start: 4.75,end: 5.06,displayUnit: 'MHz' }, '49m': { tune: 5.9, start: 5.9, end: 6.2, displayUnit: 'MHz' },
'41m': { tune: 7.2, start: 7.2, end: 7.6, displayUnit: 'MHz' }, '31m': { tune: 9.4, start: 9.4, end: 9.9, displayUnit: 'MHz' },
'25m': { tune: 11.6,start: 11.6,end: 12.1,displayUnit: 'MHz'}, '22m': { tune: 13.57,start:13.57,end:13.87,displayUnit: 'MHz'},
'19m': { tune: 15.1,start: 15.1,end: 15.83,displayUnit: 'MHz'}, '16m': { tune: 17.48,start:17.48,end:17.9,displayUnit: 'MHz'},
'15m': { tune: 18.9,start: 18.9,end: 19.02,displayUnit: 'MHz'}, '13m': { tune: 21.45,start:21.45,end:21.85,displayUnit: 'MHz'},
'11m': { tune: 25.67,start:25.67,end:26.1,displayUnit: 'MHz'}
};
if (pluginConfig.customSwBands) {
for (const key in pluginConfig.customSwBands) {
if (SW_BANDS[key] && pluginConfig.customSwBands[key]) {
if (pluginConfig.customSwBands[key].tune !== undefined && !isNaN(pluginConfig.customSwBands[key].tune)) SW_BANDS[key].tune = pluginConfig.customSwBands[key].tune;
if (pluginConfig.customSwBands[key].start !== undefined) SW_BANDS[key].start = pluginConfig.customSwBands[key].start;
if (pluginConfig.customSwBands[key].end !== undefined) SW_BANDS[key].end = pluginConfig.customSwBands[key].end;
}
}
}
const amBandKeys =['SW', 'MW', 'LW'];
let masterBwListTemplates =[];
const initializeBwFilter = () => {
const desktopBwList = document.querySelector('#data-bw .options');
const mobileBwList = document.querySelector('#data-bw-phone .options');
if (desktopBwList) masterBwListTemplates = Array.from(desktopBwList.querySelectorAll('li')).map(li => li.cloneNode(true));
Object.entries(FM_DX_TUNER_BW_OPTIONS).forEach(([text, command]) => {
const value = command.replace('W', '');
if (desktopBwList) {
const newLi = document.createElement('li');
newLi.textContent = text; newLi.dataset.value = value; newLi.classList.add('fmdx-tuner-bw-option'); newLi.style.display = 'none';
newLi.addEventListener('click', (ev) => {
ev.stopImmediatePropagation();
ev.preventDefault();
if(typeof socket !== 'undefined' && socket) socket.send(command);
const root = document.getElementById('data-bw')?.closest('.dropdown') || document.getElementById('data-bw');
if(root) {
const sel = root.querySelector('.selected');
if(sel) sel.textContent = text;
root.classList.remove('open','active','show');
}
const allOptions = desktopBwList.querySelectorAll('li');
allOptions.forEach(li => li.classList.remove('bw-option-selected'));
newLi.classList.add('bw-option-selected');
localStorage.setItem('lastKnownAmBw', value);
}, { capture: true });
desktopBwList.appendChild(newLi);
}
if (mobileBwList) {
const newLi = document.createElement('li');
newLi.textContent = text; newLi.dataset.value = value; newLi.classList.add('fmdx-tuner-bw-option'); newLi.style.display = 'none';
newLi.addEventListener('click', () => { if(socket) socket.send(command); });
mobileBwList.appendChild(newLi);
}
});
};
let _prevIsAmMode = null;
const updateBwOptionsForMode = (freqInMHz) => {
if (!pluginConfig.ENABLE_AM_BW) return;
const isAmMode = freqInMHz < 27.0;
const isFirstRun = (_prevIsAmMode === null);
const justEnteredAmMode = _prevIsAmMode === false && isAmMode;
if (isAmMode && (isFirstRun || justEnteredAmMode)) {
const lastBw = localStorage.getItem('lastKnownAmBw');
if (lastBw) {
setTimeout(() => {
if (typeof socket !== 'undefined' && socket && socket.readyState === WebSocket.OPEN) {
console.log("[Enhanced Tuning] Synkroniserer BW med hardware: W" + lastBw);
socket.send(`W${lastBw}`);
}
}, 300);
}
}
const justLeftAmMode = _prevIsAmMode === true && !isAmMode;
if (justLeftAmMode && typeof socket !== 'undefined' && socket) {
socket.send('W0');
const dt = document.querySelector('#data-bw .selected'); if(dt) dt.textContent = 'Auto';
const mt = document.querySelector('#data-bw-phone .selected'); if(mt) mt.textContent = 'Auto';
}
const allBwItems = document.querySelectorAll('#data-bw .options li, #data-bw-phone .options li');
if (allBwItems.length === 0) return;
const amValuesToShow = new Set(['0', ...Object.keys(AM_BW_MAPPING)]);
allBwItems.forEach(li => {
const isTunerOption = li.classList.contains('fmdx-tuner-bw-option');
const value = li.dataset.value;
if (isAmMode) {
if (pluginConfig.FIRMWARE_TYPE === 'FM-DX-Tuner') li.style.display = isTunerOption ? '' : 'none';
else {
const isRel = !isTunerOption && amValuesToShow.has(value);
li.style.display = isRel ? '' : 'none';
if (isRel && AM_BW_MAPPING.hasOwnProperty(value)) li.textContent = `${AM_BW_MAPPING[value]} kHz`;
}
} else {
li.style.display = isTunerOption ? 'none' : '';
if (!isTunerOption && value) {
const template = masterBwListTemplates.find(t => t.dataset.value === value);
if (template) li.textContent = template.textContent;
}
}
});
if (isAmMode && pluginConfig.FIRMWARE_TYPE === 'TEF6686_ESP32') {
const desktopBwList = document.querySelector('#data-bw .options');
if (desktopBwList && pluginConfig.ENABLE_DEFAULT_AM_BW && _prevIsAmMode !== true && pluginConfig.DEFAULT_AM_BW_VALUE) {
const targetLi = desktopBwList.querySelector(`li[data-value="${pluginConfig.DEFAULT_AM_BW_VALUE}"]`);
if (targetLi) targetLi.click();
}
}
const currentSavedBw = localStorage.getItem('lastKnownAmBw');
if (currentSavedBw) {
allBwItems.forEach(li => {
if (li.dataset.value === currentSavedBw) {
li.classList.add('bw-option-selected');
} else {
li.classList.remove('bw-option-selected');
}
});
}
_prevIsAmMode = isAmMode;
};
const LOOP_STORAGE_KEY = 'bandSelectorLoopState';
const LAST_FREQS_STORAGE_KEY = 'bandSelectorLastFreqs';
const FULL_SW_MODE_KEY = 'bandSelectorFullSwMode';
let loopEnabled = localStorage.getItem(LOOP_STORAGE_KEY) === 'true';
if (!pluginConfig.SHOW_LOOP_BUTTON) { loopEnabled = false; localStorage.setItem(LOOP_STORAGE_KEY, 'false'); }
let activeBandForLooping = null;
let fullSwTuningActive = sessionStorage.getItem(FULL_SW_MODE_KEY) === 'true';
const freqContainer = document.getElementById("freq-container");
const rtContainer = document.getElementById('rt-container');
const tuneUpButton = document.getElementById('freq-up');
const tuneDownButton = document.getElementById('freq-down');
if (!freqContainer || !rtContainer) return;
let observer;
let updateFrequencyDisplayWithMarker = () => {};
let tuneEventHandler = () => {};
let handleCustomStepTune = () => false;
const tuneToFrequency = (frequencyInMHz) => safeTuneToFrequency(frequencyInMHz);
// Scanner Logic Overrides
_stopLocalScanner = () => {
isLocalScannerRunning = false;
clearTimeout(localScannerTimer);
const scanBtn = document.getElementById('Scan-on-off');
const btnDown = document.getElementById('freq-down');
const btnUp = document.getElementById('freq-up');
const searchDown = document.getElementById('search-down');
const searchUp = document.getElementById('search-up');
const blinkers = document.querySelectorAll('.autoscan-blink');
if (scanBtn) {
scanBtn.classList.remove('bg-color-4');
scanBtn.dataset.scanStatus = "off";
}
if (btnDown) btnDown.style.display = '';
if (btnUp) btnUp.style.display = '';
if (searchDown) searchDown.style.display = '';
if (searchUp) searchUp.style.display = '';
blinkers.forEach(b => b.style.display = 'none');
};
const doLocalScannerStep = () => {
if (!isLocalScannerRunning) return;
const currentFreq = getCurrentFrequencyInMHz();
let activeBandKey = 'SW', bStart = 1.710, bEnd = 30.0, stepSize = 0.005;
for (const key in SW_BANDS) {
if (currentFreq >= SW_BANDS[key].start && currentFreq <= SW_BANDS[key].end) {
activeBandKey = key; bStart = SW_BANDS[key].start; bEnd = SW_BANDS[key].end; stepSize = 0.005; break;
}
}
if (currentFreq >= ALL_BANDS['MW'].start && currentFreq <= ALL_BANDS['MW'].end) {
activeBandKey = 'MW'; bStart = ALL_BANDS['MW'].start; bEnd = ALL_BANDS['MW'].end;
const is10kHz = localStorage.getItem('mwStepPreference') === 'true' || pluginConfig.TUNING_STANDARD === 'americas';
stepSize = is10kHz ? 0.010 : 0.009;
}
else if (currentFreq >= ALL_BANDS['LW'].start && currentFreq <= ALL_BANDS['LW'].end) {
activeBandKey = 'LW'; bStart = ALL_BANDS['LW'].start; bEnd = ALL_BANDS['LW'].end; stepSize = 0.009;
}
let newFreq = currentFreq + (localScannerDir === 'up' ? stepSize : -stepSize);
if (newFreq > bEnd) newFreq = bStart;
if (newFreq < bStart) newFreq = bEnd;
tuneToFrequency(newFreq.toFixed(3));
localScannerTimer = setTimeout(() => {
if (!isLocalScannerRunning) return;
const sigEl = document.getElementById('data-sig');
let signalValue = 0;
if (sigEl) {
const match = sigEl.innerText.match(/-?\d+(\.\d+)?/);
if (match) signalValue = parseFloat(match[0]);
}
const thresholdKey = 'thr_' + activeBandKey;
const threshold = (pluginConfig && pluginConfig[thresholdKey] !== undefined) ? pluginConfig[thresholdKey] : 35;
if (!isNaN(signalValue) && signalValue >= threshold) {
if (localScannerMode === 'search') _stopLocalScanner();
else if (localScannerMode === 'scan') {
const holdInput = document.querySelector('input[title="Scanhold Time"]');
let holdMs = 5000;
if (holdInput) holdMs = parseInt(holdInput.getAttribute('data-value'), 10) || 5000;
localScannerTimer = setTimeout(doLocalScannerStep, holdMs);
}
} else {
doLocalScannerStep();
}
}, 800);
};
_startLocalScanner = (mode, dir) => {
_stopLocalScanner();
isLocalScannerRunning = true; localScannerMode = mode; localScannerDir = dir;
if (mode === 'scan') {
const scanBtn = document.getElementById('Scan-on-off');
const btnDown = document.getElementById('freq-down');
const btnUp = document.getElementById('freq-up');
const searchDown = document.getElementById('search-down');
const searchUp = document.getElementById('search-up');
const blinkers = document.querySelectorAll('.autoscan-blink');
if (scanBtn) { scanBtn.classList.add('bg-color-4'); scanBtn.dataset.scanStatus = "on"; }
if (btnDown) btnDown.style.display = 'none'; if (btnUp) btnUp.style.display = 'none';
if (searchDown) searchDown.style.display = 'none'; if (searchUp) searchUp.style.display = 'none';
blinkers.forEach(b => b.style.display = 'inline-block');
}
doLocalScannerStep();
};
if (pluginConfig.ENABLE_TUNE_STEP_FEATURE || pluginConfig.TUNING_STANDARD === 'americas') {
if (pluginConfig.ENABLE_TUNE_STEP_FEATURE) {
const TUNE_STEP_CONFIG =[
{ step: 0.001, markerIndex: -1 }, { step: 0.010, markerIndex: -2 },
{ step: 0.100, markerIndex: -3 }, { step: 1.000, markerIndex: -5 },
];
let currentTuneStepIndex = -1;
let tuneStepResetTimer = null;
let startResetTimer = () => {};
if (pluginConfig.TUNE_STEP_TIMEOUT_SECONDS > 0) {
const resetTuneStep = () => { currentTuneStepIndex = -1; updateFrequencyDisplayWithMarker(); };
startResetTimer = () => {
clearTimeout(tuneStepResetTimer);
if (currentTuneStepIndex !== -1) tuneStepResetTimer = setTimeout(resetTuneStep, pluginConfig.TUNE_STEP_TIMEOUT_SECONDS * 1000);
};
const clearResetTimer = () => clearTimeout(tuneStepResetTimer);
freqContainer.addEventListener('mouseenter', clearResetTimer);
freqContainer.addEventListener('mouseleave', startResetTimer);
}
updateFrequencyDisplayWithMarker = () => {
const originalText = dataFrequencyElement.textContent;
if (observer) observer.disconnect();
if (currentTuneStepIndex === -1) {
dataFrequencyElement.innerHTML = originalText;
} else {
const config = TUNE_STEP_CONFIG[currentTuneStepIndex];
const textOnly = originalText.replace(/[^\d.]/g, '');
const chars = textOnly.split('');
const markerPos = chars.length + config.markerIndex;
let html = '';
for (let i = 0; i < chars.length; i++) {
if (i === markerPos && chars[i] !== '.') html += `<span class="freq-digit-marker">${chars[i]}</span>`;
else html += `<span>${chars[i]}</span>`;
}
dataFrequencyElement.innerHTML = html;
}
applyFreqDecimalHiding();
if (observer) observer.observe(dataFrequencyElement, { characterData: true, childList: true, subtree: true });
};
handleCustomStepTune = (direction) => {
if (currentTuneStepIndex === -1) return false;
const currentFreq = getCurrentFrequencyInMHz();
if (isNaN(currentFreq)) return true;
let newFreq;
const isFmBand = (currentFreq >= 64.0);
const directionMultiplier = (direction === 'up' ? 1 : -1);
if (isFmBand) {
let stepSize = TUNE_STEP_CONFIG[currentTuneStepIndex].step;
if (currentTuneStepIndex === 2) {
stepSize = (pluginConfig.TUNING_STANDARD === 'americas') ? 0.200 : 0.100;
newFreq = currentFreq + (stepSize * directionMultiplier);
newFreq = Math.round(newFreq * 10) / 10;
} else {
newFreq = currentFreq + (stepSize * directionMultiplier);
}
} else {
const stepSize = TUNE_STEP_CONFIG[currentTuneStepIndex].step;
newFreq = currentFreq + (stepSize * directionMultiplier);
if (currentTuneStepIndex === 1) {
let bandStep = 0.005;
if (newFreq >= ALL_BANDS['MW'].start && newFreq <= ALL_BANDS['MW'].end) {
const isAmericasTuning = (pluginConfig.TUNING_STANDARD === 'americas');
const storedPref = localStorage.getItem('mwStepPreference');
const is10kHzStep = storedPref ? (storedPref === 'true') : isAmericasTuning;
bandStep = is10kHzStep ? 0.010 : 0.009;
} else if (newFreq >= ALL_BANDS['LW'].start && newFreq <= ALL_BANDS['LW'].end) {
bandStep = 0.009;
}
newFreq = Math.round(newFreq / bandStep) * bandStep;
}
}
tuneToFrequency(newFreq.toFixed(3));
startResetTimer();
return true;
};
freqContainer.addEventListener('click', (e) => {
if (e.target.closest('#mw-step-toggle-button, .loop-toggle-button, #band-range-container, .band-selector-button')) return;
e.preventDefault(); e.stopImmediatePropagation();
currentTuneStepIndex++;
const freqInMHz = getCurrentFrequencyInMHz();
const isFmBand = (freqInMHz >= 64.0);
if (isFmBand && currentTuneStepIndex === 0) currentTuneStepIndex = 1;
if (currentTuneStepIndex >= TUNE_STEP_CONFIG.length) currentTuneStepIndex = -1;
updateFrequencyDisplayWithMarker();
startResetTimer();
});
}
const handleAmericasDefaultStepTune = (direction) => {
const currentFreq = getCurrentFrequencyInMHz();
if (isNaN(currentFreq)) return;
let newFreq;
if (currentFreq >= ALL_BANDS['FM'].start && currentFreq <= ALL_BANDS['FM'].end) {
const step = 0.2;
newFreq = parseFloat(currentFreq.toFixed(1));
if (direction === 'up') newFreq += step; else newFreq -= step;
if (Math.round(newFreq * 10) % 2 === 0) newFreq -= 0.1;
} else {
const step = 0.010;
const directionMultiplier = (direction === 'up' ? 1 : -1);
newFreq = Math.round((currentFreq + (step * directionMultiplier)) / step) * step;
}
tuneToFrequency(newFreq.toFixed(3));
};
tuneEventHandler = (event, direction) => {
if (pluginConfig.SHOW_LOOP_BUTTON && loopEnabled && activeBandForLooping) {
const currentFreq = getCurrentFrequencyInMHz();
const tolerance = 0.0001;
let looped = false;
if (
direction === 'up' && currentFreq >= activeBandForLooping.end - tolerance) {
tuneToFrequency(activeBandForLooping.start); looped = true;
} else if (direction === 'down' && currentFreq <= activeBandForLooping.start + tolerance) {
tuneToFrequency(activeBandForLooping.end); looped = true;
}
if (looped) { event.preventDefault(); event.stopImmediatePropagation(); return; }
}
if (handleCustomStepTune(direction)) {
event.preventDefault(); event.stopImmediatePropagation(); return;
}
if (pluginConfig.TUNING_STANDARD === 'americas') {
const currentFreq = getCurrentFrequencyInMHz();
const isFmBand = currentFreq >= ALL_BANDS['FM'].start && currentFreq <= ALL_BANDS['FM'].end;
const isMwBand = currentFreq >= ALL_BANDS['MW'].start && currentFreq <= ALL_BANDS['MW'].end;
if (isFmBand || isMwBand) {
handleAmericasDefaultStepTune(direction);
event.preventDefault(); event.stopImmediatePropagation();
}
}
};
freqContainer.addEventListener('wheel', (e) => tuneEventHandler(e, e.deltaY < 0 ? 'up' : 'down'), true);
if(tuneUpButton) tuneUpButton.addEventListener('click', (e) => tuneEventHandler(e, 'up'), true);
if(tuneDownButton) tuneDownButton.addEventListener('click', (e) => tuneEventHandler(e, 'down'), true);
freqContainer.addEventListener('wheel', (e) => tuneEventHandler(e, e.deltaY < 0 ? 'up' : 'down'), true);
if(tuneUpButton) tuneUpButton.addEventListener('click', (e) => tuneEventHandler(e, 'up'), true);
if(tuneDownButton) tuneDownButton.addEventListener('click', (e) => tuneEventHandler(e, 'down'), true);
// --- FIX FOR FAST FREQUENCY & INNER KNOB VISUALS ---
let lastKeyTuneTime = 0;
let finalKeyTuneTimer = null;
let virtualKeyFreq = null;
document.addEventListener('keydown', (e) => {
// 1. Ignore if typing into an input field
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
// 2. Hard block native webserver hotkeys to prevent ESP32 crashes
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
// 3. DETERMINE DIRECTION (Restored to standard: Up/Right = increase, Down/Left = decrease)
let direction;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') direction = 'up';
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') direction = 'down';
const isFineTuning = (e.key === 'ArrowUp' || e.key === 'ArrowDown');
// 4. CRITICAL FIX FOR THE INTEGRATED ANALOG SCALE ENGINE
// Tells the Canvas Engine directly which knob should be animated
if (typeof AnalogScaleEngine !== 'undefined') {
AnalogScaleEngine.isFineTuningMode = isFineTuning;
AnalogScaleEngine.lastKnobState = ""; // Forces immediate redraw of the knobs
}
// 5. Engine for scrolling
let handledByCustom = false;
if (typeof handleCustomStepTune === 'function') {
const now = Date.now();
if (now - lastKeyTuneTime > 150) {
handledByCustom = handleCustomStepTune(direction);
if (handledByCustom) lastKeyTuneTime = now;
} else {
if (handleCustomStepTune(direction)) handledByCustom = true;
}
}
if (!handledByCustom) {
let baseFreq = virtualKeyFreq !== null ? virtualKeyFreq : getCurrentFrequencyInMHz();
if (isNaN(baseFreq)) return;
let step = 0.1; // Default standard FM
if (isFineTuning) {
// INNER KNOB FIX: 10 kHz (0.01) for FM, 1 kHz (0.001) for AM/SW
step = (baseFreq < 30.0) ? 0.001 : 0.01;
} else {
// OUTER KNOB: Standard Left/Right tuning logic
if (baseFreq < 30.0) {
step = 0.005;
if (baseFreq >= ALL_BANDS['MW'].start && baseFreq <= ALL_BANDS['MW'].end) {
const is10kHz = localStorage.getItem('mwStepPreference') === 'true' || pluginConfig.TUNING_STANDARD === 'americas';
step = is10kHz ? 0.010 : 0.009;
} else if (baseFreq >= ALL_BANDS['LW'].start && baseFreq <= ALL_BANDS['LW'].end) {
step = 0.009;
}
} else if (pluginConfig.TUNING_STANDARD === 'americas') {
step = 0.2;
}
}
const dirMult = direction === 'up' ? 1 : -1;
virtualKeyFreq = baseFreq + (step * dirMult);
// Rounding safeguards
if (isFineTuning) {
// Fix floating point errors for up to 3 decimals (1 kHz precision)
virtualKeyFreq = Math.round(virtualKeyFreq * 1000) / 1000;
} else {
if (baseFreq < 30.0) {
virtualKeyFreq = Math.round(virtualKeyFreq / step) * step;
} else if (pluginConfig.TUNING_STANDARD === 'americas') {
if (Math.round(virtualKeyFreq * 10) % 2 === 0) virtualKeyFreq -= 0.1;
}
}
// 6. FORCE DOM UPDATE
const freqDisplay = document.getElementById('frequency') || document.querySelector('.freq-value');
if (freqDisplay) {
freqDisplay.innerText = virtualKeyFreq.toFixed(3);
}
if (typeof updateVisualsByFrequency === 'function') updateVisualsByFrequency(virtualKeyFreq);
// 7. Throttling / Debouncing to protect the ESP32
const now = Date.now();
if (now - lastKeyTuneTime > 200) {
safeTuneToFrequency(virtualKeyFreq.toFixed(3));
lastKeyTuneTime = now;
}
clearTimeout(finalKeyTuneTimer);
finalKeyTuneTimer = setTimeout(() => {
safeTuneToFrequency(virtualKeyFreq.toFixed(3));
virtualKeyFreq = null;
lastKeyTuneTime = Date.now();
}, 250);
}
}
}, true);
// -------------------------------------------------------------
}
let updateVisualsByFrequency;
const updateBandButtonStates = () => {
const allBandData = { ...ALL_BANDS, ...SW_BANDS };
let limits = null;
if (pluginConfig && pluginConfig.overrideServerTuningLimit) {
limits = {
useCustom: true, fmL: pluginConfig.fmLower, fmU: pluginConfig.fmUpper, amL: pluginConfig.amLower, amU: pluginConfig.amUpper
};
}
document.querySelectorAll('[data-band-key],[data-band-name]').forEach(button => {
const key = button.dataset.bandKey || button.dataset.bandName;
const bandData = allBandData[key];
if (bandData && limits) {
const bandStartMHz = bandData.start;
const bandEndMHz = bandData.end;
let isOutside = false;
if (limits.useCustom) {
const isFmBand = bandEndMHz >= 64.0;
const lowerLimit = isFmBand ? limits.fmL : limits.amL;
const upperLimit = isFmBand ? limits.fmU : limits.amU;
isOutside = bandEndMHz < lowerLimit || bandStartMHz > upperLimit;
}
button.classList.toggle('disabled-band', isOutside);
const parent = button.parentElement;
const isAlreadyWrapped = parent && parent.classList.contains('bs-tooltip');
if (pluginConfig.LAYOUT_STYLE === 'modern') {
if (isOutside && !isAlreadyWrapped) {
const tooltipWrapper = document.createElement('div');
tooltipWrapper.className = 'bs-tooltip';
const tooltipText = document.createElement('span');
tooltipText.className = 'bs-tooltiptext';
tooltipText.id = `tooltip-band-${key}`;
tooltipText.textContent = 'This band is outside the tuning limits.';
button.parentNode.insertBefore(tooltipWrapper, button);
tooltipWrapper.appendChild(button);
tooltipWrapper.appendChild(tooltipText);
} else if (!isOutside && isAlreadyWrapped) {
const grandParent = parent.parentNode;
grandParent.insertBefore(button, parent);
grandParent.removeChild(parent);
}
}
}
});
};
if (pluginConfig.LAYOUT_STYLE === 'modern') {
const layoutWrapper = document.createElement('div');
const sideButtonContainer = document.createElement('div');
const amBandsViewContainer = document.createElement('div');
const mobileBandSelectorWrapper = document.createElement('div');
const mobileBandSelector = document.createElement('select');
const bandRangeContainer = document.createElement("div");
const loopButton = document.createElement("button");
const updateBandRangeDisplay = (band) => {
if (!pluginConfig.SHOW_BAND_RANGE || !band) { bandRangeContainer.style.display = 'none'; return; }
bandRangeContainer.style.display = 'flex';
const unit = band.displayUnit || 'MHz';
const start = unit === 'kHz' ? Math.round(band.start * 1000) : band.start.toFixed(3);
const end = unit === 'kHz' ? Math.round(band.end * 1000) : band.end.toFixed(3);
bandRangeContainer.querySelector('.band-range-start').innerHTML = `${start} <span class="band-range-unit">${unit}</span>`;
bandRangeContainer.querySelector('.band-range-end').innerHTML = `${end} <span class="band-range-unit">${unit}</span>`;
};
const updateView = (activeBandKey) => {
if (pluginConfig.HIDE_ALL_BUTTONS) return;
const freqForAmCheck = getCurrentFrequencyInMHz();
const isAmView = (freqForAmCheck >= ALL_BANDS.AM_SUPER.start && freqForAmCheck <= ALL_BANDS.AM_SUPER.end);
rtContainer.style.display = isAmView ? 'none' : 'block';
amBandsViewContainer.style.display = isAmView ? 'grid' : 'none';
};
const createBandButton = (key, data, cssClass) => {
const button = document.createElement("button");
button.className = cssClass;
button.textContent = data.displayName || key.replace('m', '');
button.dataset.bandKey = key;
return button;
};
updateVisualsByFrequency = (freqInMHz) => {
let currentMainKey = null, currentSwKey = null;
for (const key in ALL_BANDS) { if (key !== 'AM_SUPER' && enabledBandsList.includes(key) && freqInMHz >= ALL_BANDS[key].start && freqInMHz <= ALL_BANDS[key].end) { currentMainKey = key; break; } }
if (currentMainKey === 'SW') {
for (const key in SW_BANDS) { if (freqInMHz >= SW_BANDS[key].start && freqInMHz <= SW_BANDS[key].end) { currentSwKey = key; break; } }
}
if (pluginConfig.ENABLE_FREQUENCY_MEMORY) {
try {
const lastFreqs = JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {};
if (currentSwKey) lastFreqs[currentSwKey] = freqInMHz;
if (currentMainKey) lastFreqs[currentMainKey] = freqInMHz;
localStorage.setItem(LAST_FREQS_STORAGE_KEY, JSON.stringify(lastFreqs));
} catch (e) { }
}
const bandForDisplay = (currentMainKey === 'SW' && fullSwTuningActive) ? ALL_BANDS['SW'] : (SW_BANDS[currentSwKey] || ALL_BANDS[currentMainKey]);
updateView(currentMainKey);
updateBandRangeDisplay(bandForDisplay);
activeBandForLooping = bandForDisplay;
// ANALOG SCALE BINDING (MODERN)
AnalogScaleEngine.updateBand(bandForDisplay, freqInMHz, currentSwKey || currentMainKey);
if (amBandKeys.includes(currentMainKey)) localStorage.setItem('bandSelectorLastAmBand', currentMainKey);
const activeKeys = new Set();
if (currentMainKey) activeKeys.add(currentMainKey);
if (currentSwKey) activeKeys.add(currentSwKey);
if (amBandKeys.includes(currentMainKey)) activeKeys.add('AM');
document.querySelectorAll('.band-selector-button, .am-view-button, .sw-grid-button').forEach(btn => { btn.classList.toggle('active-band', activeKeys.has(btn.dataset.bandKey)); });
if (mobileBandSelector && currentMainKey) { if (document.activeElement !== mobileBandSelector) mobileBandSelector.value = currentMainKey; }
const loopOption = document.getElementById('mobile-loop-toggle-option');
if (loopOption) loopOption.textContent = loopEnabled ? 'Disable Band Loop' : 'Enable Band Loop';
const antContainer = document.getElementById('data-ant-container');
const swSelectorWrapper = document.getElementById('mobile-sw-band-selector-wrapper');
const mobileSwBandSelector = document.getElementById('mobile-sw-band-selector');
if (antContainer && swSelectorWrapper) {
if (currentMainKey === 'SW') {
swSelectorWrapper.style.display = 'flex';
antContainer.classList.add('sw-mode-active');
} else {
swSelectorWrapper.style.display = 'none';
antContainer.classList.remove('sw-mode-active');
}
}
if (mobileSwBandSelector) mobileSwBandSelector.value = currentSwKey || '';
updateFrequencyDisplayWithMarker();
};
if (pluginConfig.SHOW_LOOP_BUTTON) {
loopButton.className = 'loop-toggle-button'; loopButton.innerHTML = 'Band<br>Loop';
loopButton.title = 'Enable/disable frequency loop'; loopButton.classList.toggle('active', loopEnabled);
freqContainer.appendChild(loopButton);
loopButton.addEventListener('click', (e) => { e.stopPropagation(); loopEnabled = !loopEnabled; loopButton.classList.toggle('active', loopEnabled); localStorage.setItem(LOOP_STORAGE_KEY, loopEnabled); });
}
if (pluginConfig.SHOW_BAND_RANGE) {
bandRangeContainer.id = "band-range-container"; bandRangeContainer.innerHTML = `<span class="band-range-part band-range-start"></span><span class="range-separator">↔</span><span class="band-range-part band-range-end"></span>`;
freqContainer.appendChild(bandRangeContainer);
bandRangeContainer.querySelector('.band-range-start').addEventListener('click', () => { if (activeBandForLooping) tuneToFrequency(activeBandForLooping.start); });
bandRangeContainer.querySelector('.band-range-end').addEventListener('click', () => { if (activeBandForLooping) tuneToFrequency(activeBandForLooping.end); });
}
if (!pluginConfig.HIDE_ALL_BUTTONS) {
layoutWrapper.className = `band-selector-layout-wrapper ${rtContainer.className}`; rtContainer.className = '';
rtContainer.parentNode.replaceChild(layoutWrapper, rtContainer);
sideButtonContainer.className = 'side-band-button-container'; layoutWrapper.appendChild(sideButtonContainer);
layoutWrapper.appendChild(rtContainer);
amBandsViewContainer.className = 'am-bands-view-container'; layoutWrapper.appendChild(amBandsViewContainer);
const sideButtonKeys =['FM', 'OIRT', 'AM'];
sideButtonKeys.forEach(key => {
const isAmButton = (key === 'AM');
const shouldCreate = isAmButton ? amBandKeys.some(b => enabledBandsList.includes(b)) : enabledBandsList.includes(key);
if (shouldCreate) {
const btn = createBandButton(key, { displayName: key === 'AM' ? ALL_BANDS['AM_SUPER'].name : ALL_BANDS[key].name }, 'band-selector-button');
btn.addEventListener('click', () => {
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
let targetFreq;
if (isAmButton) {
const lastAmBand = localStorage.getItem('bandSelectorLastAmBand');
if (lastAmBand && enabledBandsList.includes(lastAmBand) && lastFreqs[lastAmBand]) targetFreq = lastFreqs[lastAmBand];
else {
const firstEnabledAmBand = amBandKeys.find(b => enabledBandsList.includes(b));
if (firstEnabledAmBand) targetFreq = lastFreqs[firstEnabledAmBand] || ALL_BANDS[firstEnabledAmBand].tune;
}
} else {
fullSwTuningActive = false; sessionStorage.setItem(FULL_SW_MODE_KEY, 'false');
targetFreq = lastFreqs[key] || ALL_BANDS[key].tune;
}
if (targetFreq !== undefined) tuneToFrequency(targetFreq);
updateVisualsByFrequency(getCurrentFrequencyInMHz());
});
sideButtonContainer.appendChild(btn);
}
});
if (enabledBandsList.includes('SW')) {
const swFieldset = document.createElement('fieldset'); swFieldset.className = 'sw-bands-fieldset';
const swLegend = document.createElement('legend'); swLegend.textContent = 'SW Broadcast Band'; swFieldset.appendChild(swLegend);
const swGridContainer = document.createElement('div'); swGridContainer.className = 'sw-grid-container'; swFieldset.appendChild(swGridContainer);
Object.keys(SW_BANDS).forEach(key => {
const btn = createBandButton(key, SW_BANDS[key], 'sw-grid-button');
btn.addEventListener('click', () => {
fullSwTuningActive = false; sessionStorage.setItem(FULL_SW_MODE_KEY, 'false');
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[key] || SW_BANDS[key].tune;
tuneToFrequency(targetFreq);
updateVisualsByFrequency(getCurrentFrequencyInMHz());
});
swGridContainer.appendChild(btn);
});
amBandsViewContainer.appendChild(swFieldset);
}
const bandFieldset = document.createElement('fieldset'); bandFieldset.className = 'band-fieldset';
const bandLegend = document.createElement('legend'); bandLegend.textContent = 'AM Band'; bandFieldset.appendChild(bandLegend);
const bandButtonContainer = document.createElement('div'); bandButtonContainer.className = 'band-button-container'; bandFieldset.appendChild(bandButtonContainer);
if (enabledBandsList.includes('SW')) {
const fullSwButton = createBandButton('SW', { ...ALL_BANDS['SW'], displayName: 'SW' }, 'am-view-button');
fullSwButton.addEventListener('click', () => {
fullSwTuningActive = true; sessionStorage.setItem(FULL_SW_MODE_KEY, 'true');
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs['SW'] || ALL_BANDS['SW'].tune;
tuneToFrequency(targetFreq);
updateVisualsByFrequency(getCurrentFrequencyInMHz());
});
bandButtonContainer.appendChild(fullSwButton);
}['MW', 'LW'].forEach(key => {
if (enabledBandsList.includes(key)) {
const btn = createBandButton(key, ALL_BANDS[key], 'am-view-button');
btn.addEventListener('click', () => {
fullSwTuningActive = false; sessionStorage.setItem(FULL_SW_MODE_KEY, 'false');
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[key] || ALL_BANDS[key].tune;
tuneToFrequency(targetFreq);
updateVisualsByFrequency(getCurrentFrequencyInMHz());
});
bandButtonContainer.appendChild(btn);
}
});
if (bandButtonContainer.hasChildNodes()) amBandsViewContainer.prepend(bandFieldset);
const rtContainerForAnchor = document.getElementById('rt-container');
if (rtContainerForAnchor && rtContainerForAnchor.parentNode) {
let antContainer = document.getElementById('data-ant-container');
if (!antContainer) {
antContainer = document.createElement('div'); antContainer.id = 'data-ant-container'; antContainer.className = 'hide-desktop';
rtContainerForAnchor.parentNode.insertBefore(antContainer, rtContainerForAnchor);
}
mobileBandSelectorWrapper.id = 'mobile-band-selector-wrapper'; mobileBandSelector.id = 'mobile-band-selector';
const mobileBandOrder =['FM', 'OIRT', 'SW', 'MW', 'LW'];
mobileBandOrder.forEach(key => {
if (enabledBandsList.includes(key)) {
const option = document.createElement('option'); option.value = key; option.textContent = ALL_BANDS[key].name; mobileBandSelector.appendChild(option);
}
});
if (pluginConfig.SHOW_LOOP_BUTTON) {
const separator = document.createElement('option'); separator.disabled = true; separator.textContent = '──────────'; mobileBandSelector.appendChild(separator);
const loopOption = document.createElement('option'); loopOption.id = 'mobile-loop-toggle-option'; loopOption.value = 'toggle-loop';
loopOption.textContent = loopEnabled ? 'Disable Band Loop' : 'Enable Band Loop'; mobileBandSelector.appendChild(loopOption);
}
mobileBandSelectorWrapper.appendChild(mobileBandSelector); antContainer.appendChild(mobileBandSelectorWrapper);
const mobileSwBandSelectorWrapper = document.createElement('div'); mobileSwBandSelectorWrapper.id = 'mobile-sw-band-selector-wrapper'; mobileSwBandSelectorWrapper.style.display = 'none';
const mobileSwBandSelector = document.createElement('select'); mobileSwBandSelector.id = 'mobile-sw-band-selector';
const defaultSwOption = document.createElement('option'); defaultSwOption.value = ""; defaultSwOption.textContent = "Band"; mobileSwBandSelector.appendChild(defaultSwOption);
Object.keys(SW_BANDS).forEach(key => {
const option = document.createElement('option'); option.value = key; option.textContent = key; mobileSwBandSelector.appendChild(option);
});
mobileSwBandSelectorWrapper.appendChild(mobileSwBandSelector); antContainer.appendChild(mobileSwBandSelectorWrapper);
mobileSwBandSelector.addEventListener('change', (event) => {
const key = event.target.value;
if (!key) return;
const data = SW_BANDS[key];
if (!data) return;
fullSwTuningActive = false;
sessionStorage.setItem(FULL_SW_MODE_KEY, 'false');
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[key] || data.tune;
tuneToFrequency(targetFreq);
});
mobileBandSelector.addEventListener('change', (event) => {
const key = event.target.value;
if (key === 'toggle-loop') {
loopEnabled = !loopEnabled; localStorage.setItem(LOOP_STORAGE_KEY, loopEnabled);
if (loopButton) loopButton.classList.toggle('active', loopEnabled);
const freqMhz = getCurrentFrequencyInMHz();
let currentMainKey = null;
for (const bandKey in ALL_BANDS) { if (freqMhz >= ALL_BANDS[bandKey].start && freqMhz <= ALL_BANDS[bandKey].end) { currentMainKey = bandKey; break; } }
if (currentMainKey) mobileBandSelector.value = currentMainKey;
updateVisualsByFrequency(freqMhz); return;
}
const data = ALL_BANDS[key]; if (!data) return;
if (key === 'SW') fullSwTuningActive = true; else fullSwTuningActive = false;
sessionStorage.setItem(FULL_SW_MODE_KEY, String(fullSwTuningActive));
const lastFreqs = JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {};
const targetFreq = lastFreqs[key] || data.tune;
tuneToFrequency(targetFreq);
updateVisualsByFrequency(getCurrentFrequencyInMHz());
});
}
}
} else if (pluginConfig.LAYOUT_STYLE === 'classic') {
const pluginTopContainer = document.createElement("div");
pluginTopContainer.className = "plugin-top-container";
const mainBandsWrapper = document.createElement("div");
mainBandsWrapper.className = "main-bands-wrapper";
const swBandsContainer = document.createElement("div");
swBandsContainer.className = "sw-bands-container";
const swBandsTopWrapper = document.createElement("div");
swBandsTopWrapper.className = "sw-bands-grid sw-bands-top-wrapper";
const swBandsBottomWrapper = document.createElement("div");
swBandsBottomWrapper.className = "sw-bands-grid sw-bands-bottom-wrapper";
const bandRangeContainer = document.createElement("div");
bandRangeContainer.id = "band-range-container";
const startFreqSpan = document.createElement("span");
startFreqSpan.className = "band-range-part";
startFreqSpan.title = "Go to band start";
const rangeSeparator = document.createElement("span");
rangeSeparator.className = "range-separator";
rangeSeparator.innerHTML = "↔";
const endFreqSpan = document.createElement("span");
endFreqSpan.className = "band-range-part";
endFreqSpan.title = "Go to band end";
const updateBandRangeDisplay = (start, end, unit) => {
if (!pluginConfig.SHOW_BAND_RANGE || start === undefined || end === undefined) {
bandRangeContainer.style.display = 'none';
return;
}
bandRangeContainer.style.display = 'flex';
const displayStart = unit === 'kHz' ? Math.round(start * 1000) : start.toFixed(3);
const displayEnd = unit === 'kHz' ? Math.round(end * 1000) : end.toFixed(3);
startFreqSpan.innerHTML = `${displayStart} <span class="band-range-unit">${unit}</span>`;
startFreqSpan.dataset.freqMhz = start;
endFreqSpan.innerHTML = `${displayEnd} <span class="band-range-unit">${unit}</span>`;
endFreqSpan.dataset.freqMhz = end;
};
updateVisualsByFrequency = (freqInMHz) => {
let activeMainBandKey = null;
for (const bandKey of enabledBandsList) {
const band = ALL_BANDS[bandKey];
if (band && freqInMHz >= band.start && freqInMHz <= band.end) {
activeMainBandKey = bandKey;
break;
}
}
mainBandsWrapper.querySelectorAll('.main-band-button').forEach(btn => {
btn.classList.toggle('active-band', btn.dataset.bandKey === activeMainBandKey);
});
if (activeMainBandKey) activeBandForLooping = ALL_BANDS[activeMainBandKey];
else activeBandForLooping = null;
let activeSwBandKey = null;
if (activeMainBandKey === 'SW') {
swBandsContainer.style.display = 'flex';
for (const swKey in SW_BANDS) {
const swBand = SW_BANDS[swKey];
if (freqInMHz >= swBand.start && freqInMHz <= swBand.end) {
activeSwBandKey = swKey;
break;
}
}
swBandsContainer.querySelectorAll('.sw-band-button').forEach(btn => {
btn.classList.toggle('active-band', btn.dataset.bandKey === activeSwBandKey);
});
const activeSwBand = SW_BANDS[activeSwBandKey];
if (activeSwBand) {
updateBandRangeDisplay(activeSwBand.start, activeSwBand.end, 'MHz');
activeBandForLooping = activeSwBand;
} else {
updateBandRangeDisplay(ALL_BANDS.SW.start, ALL_BANDS.SW.end, 'MHz');
activeBandForLooping = ALL_BANDS.SW;
}
} else {
swBandsContainer.style.display = 'none';
const activeMainBand = ALL_BANDS[activeMainBandKey];
if (activeMainBand) updateBandRangeDisplay(activeMainBand.start, activeMainBand.end, activeMainBand.displayUnit);
else updateBandRangeDisplay();
}
// ANALOG SCALE BINDING (CLASSIC)
AnalogScaleEngine.updateBand(activeBandForLooping, freqInMHz, activeSwBandKey || activeMainBandKey);
if (pluginConfig.ENABLE_FREQUENCY_MEMORY) {
try {
const lastFreqs = JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {};
if (activeSwBandKey) lastFreqs[activeSwBandKey] = freqInMHz;
if (activeMainBandKey) lastFreqs[activeMainBandKey] = freqInMHz;
localStorage.setItem(LAST_FREQS_STORAGE_KEY, JSON.stringify(lastFreqs));
} catch (e) { }
}
const mobileBandSelectorEl = document.getElementById('mobile-band-selector');
if (mobileBandSelectorEl && activeMainBandKey) {
if (document.activeElement !== mobileBandSelectorEl) mobileBandSelectorEl.value = activeMainBandKey;
}
const loopOption = document.getElementById('mobile-loop-toggle-option');
if (loopOption) loopOption.textContent = loopEnabled ? 'Disable Band Loop' : 'Enable Band Loop';
const antContainer = document.getElementById('data-ant-container');
const swSelectorWrapper = document.getElementById('mobile-sw-band-selector-wrapper');
const mobileSwBandSelectorEl = document.getElementById('mobile-sw-band-selector');
if (antContainer && swSelectorWrapper) {
if (activeMainBandKey === 'SW') {
swSelectorWrapper.style.display = 'flex'; antContainer.classList.add('sw-mode-active');
} else {
swSelectorWrapper.style.display = 'none'; antContainer.classList.remove('sw-mode-active');
}
}
if (mobileSwBandSelectorEl) mobileSwBandSelectorEl.value = activeSwBandKey || '';
updateFrequencyDisplayWithMarker();
};
const createBandButton = (bandKey, bandData, isSubBand = false) => {
const button = document.createElement("button");
button.className = isSubBand ? 'sw-band-button band-selector-button' : 'main-band-button band-selector-button';
button.textContent = isSubBand ? bandKey.replace('m', '') : (bandData.name || bandKey);
button.dataset.bandKey = bandKey;
const safeTune = (bandData && typeof bandData.tune === 'number') ? bandData.tune : 0;
button.title = `Go to ${safeTune.toFixed(3)} ${bandData.displayUnit || 'MHz'}`;
button.addEventListener('click', () => {
activeBandForLooping = bandData;
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[bandKey] || safeTune;
tuneToFrequency(targetFreq);
});
return button;
};
enabledBandsList.forEach((bandKey) => mainBandsWrapper.appendChild(createBandButton(bandKey, ALL_BANDS[bandKey])));
let swButtonIndex = 0;
Object.keys(SW_BANDS).forEach((swBandKey) => {
const button = createBandButton(swBandKey, SW_BANDS[swBandKey], true);
if (swButtonIndex < 9) swBandsTopWrapper.appendChild(button);
else swBandsBottomWrapper.appendChild(button);
swButtonIndex++;
});
if (pluginConfig.SHOW_LOOP_BUTTON) {
const loopButton = document.createElement("button");
loopButton.id = 'loop-toggle-button';
loopButton.className = 'band-selector-button';
loopButton.textContent = 'Loop';
loopButton.title = 'Enable/disable frequency loop';
if (loopEnabled) loopButton.classList.add('active');
loopButton.addEventListener('click', () => {
loopEnabled = !loopEnabled;
loopButton.classList.toggle('active', loopEnabled);
localStorage.setItem(LOOP_STORAGE_KEY, loopEnabled);
});
pluginTopContainer.appendChild(mainBandsWrapper);
pluginTopContainer.appendChild(loopButton);
} else {
pluginTopContainer.appendChild(mainBandsWrapper);
}
swBandsContainer.appendChild(swBandsTopWrapper);
swBandsContainer.appendChild(swBandsBottomWrapper);
startFreqSpan.addEventListener('click', (e) => { const freqMhz = parseFloat(e.target.dataset.freqMhz); if (!isNaN(freqMhz)) tuneToFrequency(freqMhz); });
endFreqSpan.addEventListener('click', (e) => { const freqMhz = parseFloat(e.target.dataset.freqMhz); if (!isNaN(freqMhz)) tuneToFrequency(freqMhz); });
const rtContainerForAnchor = document.getElementById('rt-container');
if (rtContainerForAnchor && rtContainerForAnchor.parentNode) {
let antContainer = document.getElementById('data-ant-container');
if (!antContainer) {
antContainer = document.createElement('div');
antContainer.id = 'data-ant-container';
rtContainerForAnchor.parentNode.insertBefore(antContainer, rtContainerForAnchor);
}
antContainer.classList.add('classic-mobile-controls');
const mobileBandSelectorWrapper = document.createElement('div');
mobileBandSelectorWrapper.id = 'mobile-band-selector-wrapper';
const mobileBandSelector = document.createElement('select');
mobileBandSelector.id = 'mobile-band-selector';
const mobileBandOrder =['FM', 'OIRT', 'SW', 'MW', 'LW'];
mobileBandOrder.forEach(key => {
if (enabledBandsList.includes(key)) {
const option = document.createElement('option');
option.value = key;
option.textContent = ALL_BANDS[key].name;
mobileBandSelector.appendChild(option);
}
});
if (pluginConfig.SHOW_LOOP_BUTTON) {
const separator = document.createElement('option');
separator.disabled = true; separator.textContent = '──────────';
mobileBandSelector.appendChild(separator);
const loopOption = document.createElement('option');
loopOption.id = 'mobile-loop-toggle-option'; loopOption.value = 'toggle-loop';
loopOption.textContent = loopEnabled ? 'Disable Band Loop' : 'Enable Band Loop';
mobileBandSelector.appendChild(loopOption);
}
mobileBandSelectorWrapper.appendChild(mobileBandSelector);
antContainer.appendChild(mobileBandSelectorWrapper);
const mobileSwBandSelectorWrapper = document.createElement('div');
mobileSwBandSelectorWrapper.id = 'mobile-sw-band-selector-wrapper';
mobileSwBandSelectorWrapper.style.display = 'none';
const mobileSwBandSelector = document.createElement('select');
mobileSwBandSelector.id = 'mobile-sw-band-selector';
const defaultSwOption = document.createElement('option');
defaultSwOption.value = ""; defaultSwOption.textContent = "Band";
mobileSwBandSelector.appendChild(defaultSwOption);
Object.keys(SW_BANDS).forEach(key => {
const option = document.createElement('option');
option.value = key; option.textContent = key;
mobileSwBandSelector.appendChild(option);
});
mobileSwBandSelectorWrapper.appendChild(mobileSwBandSelector);
antContainer.appendChild(mobileSwBandSelectorWrapper);
mobileSwBandSelector.addEventListener('change', (event) => {
const key = event.target.value;
if (!key) return;
const data = SW_BANDS[key];
if (!data) return;
fullSwTuningActive = false;
sessionStorage.setItem(FULL_SW_MODE_KEY, 'false');
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[key] || data.tune;
tuneToFrequency(targetFreq);
});
mobileBandSelector.addEventListener('change', (event) => {
const key = event.target.value;
if (key === 'toggle-loop') {
loopEnabled = !loopEnabled;
localStorage.setItem(LOOP_STORAGE_KEY, loopEnabled);
const classicLoopButton = document.getElementById('loop-toggle-button');
if (classicLoopButton) classicLoopButton.classList.toggle('active', loopEnabled);
const freqMhz = getCurrentFrequencyInMHz();
let currentMainKey = null;
for (const bandKey of enabledBandsList) {
if (ALL_BANDS[bandKey] && freqMhz >= ALL_BANDS[bandKey].start && freqMhz <= ALL_BANDS[bandKey].end) {
currentMainKey = bandKey; break;
}
}
if (currentMainKey) mobileBandSelector.value = currentMainKey;
updateVisualsByFrequency(freqMhz);
return;
}
const data = ALL_BANDS[key];
if (!data) return;
fullSwTuningActive = (key === 'SW');
sessionStorage.setItem(FULL_SW_MODE_KEY, String(fullSwTuningActive));
const lastFreqs = pluginConfig.ENABLE_FREQUENCY_MEMORY ? (JSON.parse(localStorage.getItem(LAST_FREQS_STORAGE_KEY)) || {}) : {};
const targetFreq = lastFreqs[key] || data.tune;
tuneToFrequency(targetFreq);
});
}
freqContainer.appendChild(pluginTopContainer);
freqContainer.appendChild(swBandsContainer);
if (pluginConfig.SHOW_BAND_RANGE) {
bandRangeContainer.appendChild(startFreqSpan);
bandRangeContainer.appendChild(rangeSeparator);
bandRangeContainer.appendChild(endFreqSpan);
freqContainer.appendChild(bandRangeContainer);
}
}
const setBodyClasses = () => {
const body = document.body;
if (pluginConfig.LAYOUT_STYLE === 'modern') body.classList.add('layout-modern');
else body.classList.add('layout-classic');
if (pluginConfig.ENABLE_TUNE_STEP_FEATURE) body.classList.add('tune-step-enabled');
if (pluginConfig.SHOW_LOOP_BUTTON) body.classList.add('loop-button-visible');
if (pluginConfig.SHOW_BAND_RANGE) body.classList.add('band-range-visible');
if (pluginConfig.LAYOUT_STYLE === 'modern' && !pluginConfig.HIDE_ALL_BUTTONS) body.classList.add('modern-buttons-visible');
};
setBodyClasses();
observer = new MutationObserver(() => {
setTimeout(() => {
const freqMhz = getCurrentFrequencyInMHz();
if (!isNaN(freqMhz)) {
if (typeof updateVisualsByFrequency === 'function') updateVisualsByFrequency(freqMhz);
if (pluginConfig.ENABLE_AM_BW) setTimeout(() => updateBwOptionsForMode(freqMhz), 150);
}
}, 0);
});
observer.observe(dataFrequencyElement, { characterData: true, childList: true, subtree: true });
const limitSpanForObserver = Array.from(document.querySelectorAll('.text-small, span')).find(el => el.textContent.includes('Limit:'));
if (limitSpanForObserver) {
const limitObserver = new MutationObserver(updateBandButtonStates);
limitObserver.observe(limitSpanForObserver, { childList: true, characterData: true, subtree: true });
}
setTimeout(() => {
const initialFreqMhz = getCurrentFrequencyInMHz();
if (pluginConfig.ENABLE_AM_BW) initializeBwFilter();
if (!isNaN(initialFreqMhz)) {
if (typeof updateVisualsByFrequency === 'function') updateVisualsByFrequency(initialFreqMhz);
if (pluginConfig.ENABLE_AM_BW) updateBwOptionsForMode(initialFreqMhz);
}
if(typeof updateBandButtonStates === 'function') updateBandButtonStates();
}, 500);
if (pluginConfig.ENABLE_MW_STEP_TOGGLE) {
const MW_STEP_STORAGE_KEY = 'mwStepPreference';
const MW_BAND_AMERICAS = { name: 'MW', tune: 1.000, start: 0.530, end: 1.700, displayUnit: 'kHz' };
const MW_BAND_INTERNATIONAL = { name: 'MW', tune: 0.999, start: 0.504, end: 1.701, displayUnit: 'kHz' };
let is10kHzStep = localStorage.getItem(MW_STEP_STORAGE_KEY)
? localStorage.getItem(MW_STEP_STORAGE_KEY) === 'true'
: (pluginConfig.TUNING_STANDARD === 'americas');
const mwStepButton = document.createElement("button");
mwStepButton.id = 'mw-step-toggle-button';
mwStepButton.textContent = is10kHzStep ? '10 kHz' : '9 kHz';
mwStepButton.title = 'Toggle MW tuning step';
const updateButtonStyle = () => { mwStepButton.classList.toggle('active', is10kHzStep); };
mwStepButton.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault();
const originalFreq = getCurrentFrequencyInMHz();
is10kHzStep = !is10kHzStep;
mwStepButton.textContent = is10kHzStep ? '10 kHz' : '9 kHz';
localStorage.setItem(MW_STEP_STORAGE_KEY, is10kHzStep);
updateButtonStyle();
ALL_BANDS.MW = is10kHzStep ? MW_BAND_AMERICAS : MW_BAND_INTERNATIONAL;
const newBand = ALL_BANDS.MW;
const newStep = is10kHzStep ? 0.010 : 0.009;
let targetFreq = Math.round(originalFreq / newStep) * newStep;
targetFreq = Math.max(newBand.start, Math.min(newBand.end, targetFreq));
if (Math.abs(targetFreq - originalFreq) > 0.0001) tuneToFrequency(targetFreq.toFixed(3));
if (typeof updateVisualsByFrequency === 'function') updateVisualsByFrequency(targetFreq);
});
freqContainer.appendChild(mwStepButton);
updateButtonStyle();
const originalUpdateVisuals = updateVisualsByFrequency;
updateVisualsByFrequency = (freqInMHz) => {
if (typeof originalUpdateVisuals === 'function') originalUpdateVisuals(freqInMHz);
const isMwBandActive = (ALL_BANDS.MW && freqInMHz >= ALL_BANDS.MW.start && freqInMHz <= ALL_BANDS.MW.end);
mwStepButton.style.display = isMwBandActive ? 'block' : 'none';
};
const originalTuneHandler = tuneEventHandler;
tuneEventHandler = (event, direction) => {
const currentFreq = getCurrentFrequencyInMHz();
const isMwBand = (ALL_BANDS.MW && currentFreq >= ALL_BANDS.MW.start && currentFreq <= ALL_BANDS.MW.end);
if (isMwBand) {
if (pluginConfig.SHOW_LOOP_BUTTON && loopEnabled) {
const tolerance = 0.0001; let looped = false;
if (direction === 'up' && currentFreq >= ALL_BANDS.MW.end - tolerance) { tuneToFrequency(ALL_BANDS.MW.start); looped = true; }
else if (direction === 'down' && currentFreq <= ALL_BANDS.MW.start + tolerance) { tuneToFrequency(ALL_BANDS.MW.end); looped = true; }
if (looped) { event.preventDefault(); event.stopImmediatePropagation(); return; }
}
if (!handleCustomStepTune(direction)) {
const step = is10kHzStep ? 0.010 : 0.009;
const directionMultiplier = (direction === 'up' ? 1 : -1);
let newFreq = Math.round((currentFreq + (step * directionMultiplier)) / step) * step;
if (pluginConfig.SHOW_LOOP_BUTTON && loopEnabled) newFreq = Math.max(ALL_BANDS.MW.start, Math.min(ALL_BANDS.MW.end, newFreq));
tuneToFrequency(newFreq.toFixed(3));
event.preventDefault(); event.stopImmediatePropagation(); return;
}
}
originalTuneHandler(event, direction);
};
}
console.log(`[Enhanced Tuning] v3.0 loaded successfully.`);
} catch (err) {
console.error("[Enhanced Tuning] Krasj under bygging av UI:", err); // You either expected this to never go public, or you're an ass if you type in non-english and also not direct it towards your country
}
};
document.addEventListener("DOMContentLoaded", async () => {
await fetchPluginConfig();
if(!pluginConfig) return;
initAdminPanel();
initializePlugin();
});
})();