// 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", ``); const tryAdd = (attempt = 0) => { if (typeof addIconToPluginPanel === "function") { try { addIconToPluginPanel(BTN_ID, "Analog Scale", "solid", "ruler-horizontal", `Retro Design Elements V1.2 ET
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 = `
${displayName} ${freqStr}
`; let locStr = isSw ? (d.country ? `${d.location || ''}[${d.country}]` : d.location) : (d.city ? `${d.city} [${d.itu || '-'}]` : ''); if (locStr) { html += `
${locStr}
`; } 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 += `
${infos.join(' • ')}
`; if (isSw) { if (d.timeUTC) html += `
UTC: ${d.timeUTC}
`; if (d.source) html += `
Source: ${d.source}
`; } else { html += `
Last seen: ${d.lastSeen ? new Date(d.lastSeen).toLocaleString() : 'Unknown'}
`; } 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 = `
Enhanced Tuning Limits (Override toggled ON via Enhanced Settings Panel)
`; 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 = ` FM Scale Brightness
`; 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 += `${chars[i]}`; else html += `${chars[i]}`; } 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} ${unit}`; bandRangeContainer.querySelector('.band-range-end').innerHTML = `${end} ${unit}`; }; 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
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 = ``; 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} ${unit}`; startFreqSpan.dataset.freqMhz = start; endFreqSpan.innerHTML = `${displayEnd} ${unit}`; 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(); }); })();