diff --git a/plugins/example.js b/plugins/example.js
deleted file mode 100644
index 24515b5..0000000
--- a/plugins/example.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// Plugin configuration, this is used in the administration when plugins are loaded
-var pluginConfig = {
- name: 'Example plugin',
- version: '1.0',
- author: 'OpenRadio',
- frontEndPath: 'example/frontend.js'
-}
-
-// Backend (server) changes can go here...
-
-// Don't change anything below here if you are making your own plugin
-module.exports = {
- pluginConfig
-}
\ No newline at end of file
diff --git a/plugins/example/frontend.js b/plugins/example/frontend.js
deleted file mode 100644
index ebcd6f7..0000000
--- a/plugins/example/frontend.js
+++ /dev/null
@@ -1 +0,0 @@
-console.log('Webserver plugins loaded successfully.');
\ No newline at end of file
diff --git a/server/datahandler.js b/server/datahandler.js
index 5dda99d..db02cae 100644
--- a/server/datahandler.js
+++ b/server/datahandler.js
@@ -27,6 +27,7 @@ var dataToSend = {
rt0: '',
rt1: '',
rt_flag: '',
+ lps: '',
ims: 0,
eq: 0,
agc: 0,
diff --git a/server/enchanced_tuning.js b/server/enchanced_tuning.js
new file mode 100644
index 0000000..80e3058
--- /dev/null
+++ b/server/enchanced_tuning.js
@@ -0,0 +1,482 @@
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const express = require('express');
+const endpointsRouter = require('./endpoints');
+
+// --- STØTTE FOR FLERE INSTANSER (--config) ---
+let configSuffix = '';
+const configArgIndex = process.argv.indexOf('--config');
+if (configArgIndex !== -1 && process.argv.length > configArgIndex + 1) {
+ configSuffix = '_' + process.argv[configArgIndex + 1];
+} else if (process.env.CONFIG) {
+ configSuffix = '_' + process.env.CONFIG;
+}
+const CONFIG_FILE = path.join(__dirname, `enhanced_tuning_config${configSuffix}.json`);
+// ---------------------------------------------
+
+// Default Configuration
+let pluginConfig = {
+ // Layout & UI
+ 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 & Hardware
+ 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',
+
+ // Limits
+ overrideServerTuningLimit: true,
+ fmLower: 64.0, fmUpper: 108.0,
+ amLower: 0.144, amUpper: 30.0,
+
+ // Egendefinerte Bånd & Navn
+ 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 }
+ },
+
+ // Plugins Integration
+ ENABLE_AM_SCANNER: false,
+ ENABLE_ANALOG_SCALE: true,
+ ANALOG_SCALE_AUTOSTART: true,
+ SHOW_TUNING_KNOB: true,
+ ANALOG_SCALE_ENABLE_FM: 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,
+
+ // AM Scanner Thresholds
+ thr_LW: 35, thr_MW: 35, thr_160m: 35, thr_120m: 35, thr_90m: 35,
+ thr_75m: 35, thr_60m: 35, thr_49m: 35, thr_41m: 35, thr_31m: 35,
+ thr_25m: 35, thr_22m: 35, thr_19m: 35, thr_16m: 35, thr_15m: 35,
+ thr_13m: 35, thr_11m: 35
+};
+
+if (fs.existsSync(CONFIG_FILE)) {
+ try {
+ const data = fs.readFileSync(CONFIG_FILE, 'utf8');
+ pluginConfig = { ...pluginConfig, ...JSON.parse(data) };
+ } catch (err) {
+ console.error('[Enhanced Tuning] Feil ved lesing av config:', err);
+ }
+}
+
+const checkStrictAdmin = (req, res, next) => {
+ if (req.session && req.session.isAdminAuthenticated) return next();
+ return res.status(401).send('Unauthorized. You must be an administrator.');
+};
+
+endpointsRouter.get('/enhanced_tuning/api/auth-check', (req, res) => {
+ res.json({
+ isAdmin: (req.session && req.session.isAdminAuthenticated) || false,
+ isTune: true,
+ config: pluginConfig
+ });
+});
+
+endpointsRouter.post('/enhanced_tuning/api/config', checkStrictAdmin, express.json(), (req, res) => {
+ try {
+ pluginConfig = { ...pluginConfig, ...req.body };
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(pluginConfig, null, 2));
+ res.json({ success: true, message: 'Settings saved successfully' });
+ } catch (err) {
+ console.error('[Enhanced Tuning] Feil ved lagring av config:', err);
+ res.status(500).json({ success: false });
+ }
+});
+
+// Admin Panel Side
+endpointsRouter.get('/enhanced_tuning/AP', checkStrictAdmin, (req, res) => {
+ const createToggleRow = (id, title, desc, isChecked) => `
+
+
+
General UI & Layout
+ ${createSelectRow('LAYOUT_STYLE', 'Visual Layout Style', 'Choose the layout design for the plugin controls.', [{val: 'modern', label:'Modern (Side Panel)'}, {val:'classic', label:'Classic (Compact)'}], pluginConfig.LAYOUT_STYLE)}
+ ${createToggleRow('HIDE_ALL_BUTTONS', 'Hide All Buttons', 'Hides new UI elements. Only effective if Layout is Modern.', pluginConfig.HIDE_ALL_BUTTONS)}
+ ${createToggleRow('SHOW_LOOP_BUTTON', 'Show Band Loop Button', 'Enable or disable the frequency loop feature entirely.', pluginConfig.SHOW_LOOP_BUTTON)}
+ ${createToggleRow('SHOW_BAND_RANGE', 'Show Band Range', 'Show the start ↔ end frequency text under the main display.', pluginConfig.SHOW_BAND_RANGE)}
+ ${createToggleRow('HIDE_DECIMAL_FOR_HF', 'Hide Decimal Point For HF Band', 'For AM/LW/MW/SW frequencies, hide the decimal point in the main frequency display.', pluginConfig.HIDE_DECIMAL_FOR_HF)}
+ ${createInputRow('HIDE_DECIMAL_HF_THRESHOLD', 'Hide Decimal Threshold (MHz)', 'Frequencies at or below this value (in MHz) will have the decimal point hidden.', pluginConfig.HIDE_DECIMAL_HF_THRESHOLD, 'number', '0.001')}
+ ${createToggleRow('ENABLE_TUNE_STEP_FEATURE', 'Tune Step Feature', 'Click frequency display to change tuning steps.', pluginConfig.ENABLE_TUNE_STEP_FEATURE)}
+ ${createInputRow('TUNE_STEP_TIMEOUT_SECONDS', 'Tune Step Timeout', 'Seconds of inactivity before step resets (0 to disable).', pluginConfig.TUNE_STEP_TIMEOUT_SECONDS, 'number')}
+
+ Enabled Bands
+ ${createToggleRow('band_FM', 'FM Band', '', pluginConfig.ENABLED_BANDS.includes('FM'))}
+ ${createToggleRow('band_OIRT', 'OIRT Band', '', pluginConfig.ENABLED_BANDS.includes('OIRT'))}
+ ${createToggleRow('band_SW', 'Shortwave (SW) Band', '', pluginConfig.ENABLED_BANDS.includes('SW'))}
+ ${createToggleRow('band_MW', 'Mediumwave (MW) Band', '', pluginConfig.ENABLED_BANDS.includes('MW'))}
+ ${createToggleRow('band_LW', 'Longwave (LW) Band', '', pluginConfig.ENABLED_BANDS.includes('LW'))}
+
+
+
+
Tuning & Hardware Options
+ ${createSelectRow('TUNING_STANDARD', 'Regional Tuning Standard', 'Controls AM/FM steps and ranges.', [{val:'international', label:'International'}, {val:'americas', label:'Americas'}, {val:'japan', label:'Japan'}], pluginConfig.TUNING_STANDARD)}
+ ${createToggleRow('ENABLE_SMART_KHZ_INPUT', 'Smart kHz Input', 'Type e.g. "693" instead of "0.693". Auto-converts numbers on AM bands to avoid plugin conflicts.', pluginConfig.ENABLE_SMART_KHZ_INPUT)}
+ ${createToggleRow('ENABLE_MW_STEP_TOGGLE', 'MW 9/10kHz Toggle Button', 'Shows a button when in MW to switch step size.', pluginConfig.ENABLE_MW_STEP_TOGGLE)}
+ ${createToggleRow('ENABLE_FREQUENCY_MEMORY', 'Frequency Memory', 'Remember last tuned frequency per band.', pluginConfig.ENABLE_FREQUENCY_MEMORY)}
+
+ AM Bandwidth Injection
+ ${createToggleRow('ENABLE_AM_BW', 'Enable Custom AM Bandwidth', 'Requires TEF6686_ESP32 firmware.', pluginConfig.ENABLE_AM_BW)}
+ ${createSelectRow('FIRMWARE_TYPE', 'Firmware Type', 'Select compatibility mode for bandwidth.', [{val:'TEF6686_ESP32', label:'TEF6686_ESP32 (PE5PVB)'}, {val:'FM-DX-Tuner', label:'FM-DX-Tuner (kkonrad)'}], pluginConfig.FIRMWARE_TYPE)}
+ ${createToggleRow('ENABLE_DEFAULT_AM_BW', 'Force Default AM BW', 'Auto-select a specific BW when entering AM.', pluginConfig.ENABLE_DEFAULT_AM_BW)}
+ ${createSelectRow('DEFAULT_AM_BW_VALUE', 'Default AM BW Value', 'Applied if forced default is enabled.', [{val:'56000', label:'3 kHz'}, {val:'64000', label:'4 kHz'}, {val:'72000', label:'6 kHz'}, {val:'84000', label:'8 kHz'}], pluginConfig.DEFAULT_AM_BW_VALUE)}
+
+
+
+
Tuning Limits & Bands Configuration
+
+ ${createToggleRow('overrideServerTuningLimit', 'Override Server Limits', 'Allow plugin to manage its own separate hardware limits for AM and FM. Set the actual values in the core "Webserver -> Tuning Options" panel.', pluginConfig.overrideServerTuningLimit)}
+
+
Main Bands Configuration
+
Rename bands (max 5 chars to fit buttons), set default start frequency, and adjust limits.
+
+${['AM_SUPER', 'FM', 'OIRT', 'SW', 'MW', 'LW'].map(key => `
+
+`).join('')}
+
+
Shortwave Sub-Bands
+
+ ${Object.keys(pluginConfig.customSwBands).map(key => `
+
+ `).join('')}
+
+
+
+
+
+
Plugin Integration
+
+
Highpoint Scanner Integration
+ ${createToggleRow('ENABLE_AM_SCANNER', 'Enable Scanner plugin override', 'Intercepts the Highpoint scanner to respect AM/SW band limits and sub-bands.', pluginConfig.ENABLE_AM_SCANNER)}
+
+
+
Define the minimum signal strength (0-100) required to pause the scanner on a specific AM/SW band.
+
+ ${['LW', 'MW', '160m', '120m', '90m', '75m', '60m', '49m', '41m', '31m', '25m', '22m', '19m', '16m', '15m', '13m', '11m'].map(t => `
+
+ ${t} Band
+
+
+ `).join('')}
+
+
+
+
Highpoint Retro Design Elements Plugin
+ ${createToggleRow('ENABLE_ANALOG_SCALE', 'Enable Analog Scale Integration', 'Injects a dynamic retro analog scale for all bands.', pluginConfig.ENABLE_ANALOG_SCALE)}
+
+
+ ${createToggleRow('ANALOG_SCALE_AUTOSTART', 'Autostart Scale', 'Automatically open the scale when tuning to a supported band.', pluginConfig.ANALOG_SCALE_AUTOSTART)}
+ ${createToggleRow('SHOW_TUNING_KNOB', 'Show Tuning Knob', 'Displays the dual rotary encoder for manual tuning.', pluginConfig.SHOW_TUNING_KNOB)}
+ ${createSliderRow('ANALOG_SCALE_BRIGHTNESS', 'Scale Brightness', 'Adjust the backlight brightness of the analog scale.', '0.2', '2.0', '0.05', pluginConfig.ANALOG_SCALE_BRIGHTNESS)}
+
+
VU Meter Configuration
+ ${createToggleRow('ENABLE_VU_METER', 'Enable VU Meter', 'Displays a stereo VU meter next to the analog scale.', pluginConfig.ENABLE_VU_METER)}
+ ${createSelectRow('VU_METER_MODE', 'VU Meter Mode', 'RMS gives smooth realistic pumping. Peak shows raw loudness.',[{val: 'RMS', label: 'RMS (Smooth & Realistic)'}, {val: 'Peak', label: 'Peak (Raw Max Levels)'}], pluginConfig.VU_METER_MODE)}
+ ${createSliderRow('VU_METER_GAIN_FM', 'VU Meter Gain (FM)', 'Calibration multiplier for the VU meter on FM/OIRT.', '0.1', '5.0', '0.1', pluginConfig.VU_METER_GAIN_FM)}
+ ${createSliderRow('VU_METER_GAIN_AM', 'VU Meter Gain (AM/SW)', 'Calibration multiplier for the VU meter on AM/SW/MW/LW.', '0.1', '5.0', '0.1', pluginConfig.VU_METER_GAIN_AM)}
+ Retro Magic Eye
+ ${createToggleRow('ENABLE_MAGIC_EYE', 'Enable Magic Eye Indicator', 'Converts the signal panel into a glowing vacuum tube indicator.', pluginConfig.ENABLE_MAGIC_EYE)}
+ Scale Overlays & Data
+ ${createToggleRow('ENABLE_PS_SCALE', 'Show FM PS Stations', 'Display RDS PS names directly on the analog FM scale.', pluginConfig.ENABLE_PS_SCALE !== false)}
+ ${createToggleRow('ENABLE_SPECTRUM_OVERLAY', 'Show Spectrum Overlay', 'Project the SDR spectrum waterfall onto the analog dial glass.', pluginConfig.ENABLE_SPECTRUM_OVERLAY !== false)}
+ ${createToggleRow('ENABLE_SW_STATIONS_SCALE', 'Show SW Stations', 'Display active shortwave stations dynamically on the analog dial.⚠️ Requires AM Station Info Plugin V1.4 or newer! ', pluginConfig.ENABLE_SW_STATIONS_SCALE !== false)}
+
+
+
+
Settings Saved Successfully!
+
+
+
+
+ `;
+ res.send(html);
+});
\ No newline at end of file
diff --git a/server/index.js b/server/index.js
index 69296ca..16d17fe 100644
--- a/server/index.js
+++ b/server/index.js
@@ -9,6 +9,8 @@ const { serverConfig } = require('./server_config');
}
require('./stream/index');
-require("./web")(serverConfig.webserver.webserverIp);
+const start = require("./web");
+require("./enchanced_tuning");
+start(serverConfig.webserver.webserverIp);
require('./server_list')();
\ No newline at end of file
diff --git a/server/rds.js b/server/rds.js
index ef6ef6b..3f97888 100644
--- a/server/rds.js
+++ b/server/rds.js
@@ -157,9 +157,12 @@ class RDSDecoder {
this.rt0_errors = Array(64).fill("10");
this.rt1 = Array(64).fill(' ');
this.rt1_errors = Array(64).fill("10");
+ this.lps = Array(64).fill(' ');
+ this.lps_errors = Array(64).fill("10");
this.data.ps = '';
- this.data.rt1 = '';
this.data.rt0 = '';
+ this.data.rt1 = '';
+ this.data.lps = '';
this.data.pty = 0;
this.data.tp = 0;
this.data.ta = 0;
@@ -350,6 +353,38 @@ class RDSDecoder {
this.data.rt_flag = 0;
this.rt1_to_clear = true;
}
+ } else if (group === 15 && version == 0) {
+ const idx = blockB & 7;
+
+ if(c_error < 2) {
+ const err = Math.ceil(c_error * (10/3));
+ const old_err = this.lps_errors[idx * 4];
+ if(err < old_err) {
+ this.lps[idx * 4] = decode_charset(blockC >> 8);
+ this.lps[idx * 4 + 1] = decode_charset(blockC & 0xFF);
+ this.lps_errors[idx * 4] = err;
+ this.lps_errors[idx * 4 + 1] = err;
+ }
+ }
+ if(d_error < 2) {
+ const err = Math.ceil(d_error * (10/3));
+ const old_err = this.lps_errors[idx * 4 + 2];
+ if(err < old_err) {
+ this.lps[idx * 4 + 2] = decode_charset(blockD >> 8);
+ this.lps[idx * 4 + 3] = decode_charset(blockD & 0xFF);
+ this.lps_errors[idx * 4 + 2] = err;
+ this.lps_errors[idx * 4 + 3] = err;
+ }
+ }
+
+ var i = this.lps.indexOf("\r")
+ while(i != -1) {
+ this.lps[i] = " ";
+ i = this.lps.indexOf("\r");
+ }
+
+ this.data.lps = this.lps.join('');
+ this.data.lps_errors = this.lps_errors.join(',');
} else {
// console.log(group, version)
}
diff --git a/views/index.ejs b/views/index.ejs
index faef2b3..0c47ef7 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -6,6 +6,7 @@
+
@@ -25,12 +26,12 @@
-
@@ -559,6 +560,7 @@
+
<% if (!noPlugins) { %>
<% plugins?.forEach(function(plugin) { %>
diff --git a/web/css/enchanced_tuning.css b/web/css/enchanced_tuning.css
new file mode 100644
index 0000000..6205f26
--- /dev/null
+++ b/web/css/enchanced_tuning.css
@@ -0,0 +1,563 @@
+/* A1: Styles for Tune Step Feature */
+body.tune-step-enabled #freq-container {
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+body.tune-step-enabled .freq-digit-marker {
+ color: #00FF00;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.et-dimmed-zero {
+ opacity: 0.1;
+}
+.et-dimmed-zero.freq-digit-marker {
+ opacity: 0.9;
+}
+
+/* A2: Styles for disabled bands (Tune Limit) */
+.disabled-band {
+ background-color: var(--color-1) !important;
+ color: var(--color-3) !important;
+ cursor: not-allowed !important;
+ pointer-events: none;
+}
+
+/* MW 9 kHz/10 kHz button */
+#mw-step-toggle-button {
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ z-index: 11;
+ display: none;
+ max-width: 50px;
+ text-align: center;
+ height: auto;
+ padding: 1px 6px;
+ font-size: 12px;
+ font-weight: bold;
+ line-height: 1.4;
+ color: var(--color-main);
+ background-color: var(--color-4);
+ border: 1px solid var(--color-2);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
+}
+
+#mw-step-toggle-button:hover {
+ background-color: var(--color-5);
+ transform: translateY(-1px);
+}
+
+#mw-step-toggle-button.active {
+ opacity: 1;
+ border-color: var(--color-4);
+}
+
+
+/* ========================================================================== */
+/* B: MODERN LAYOUT */
+/* ========================================================================== */
+
+/* B1: Basic structure for Modern Layout */
+body.layout-modern #freq-container {
+ position: relative !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+}
+
+/* B2: Main wrapper and buttons (Shown only if HIDE_ALL_BUTTONS is false) */
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper {
+ display: flex;
+ gap: 15px;
+ margin: 20px 10px 0 10px;
+ background: transparent !important;
+ padding: 0 !important;
+ backdrop-filter: none !important;
+}
+body.layout-modern.modern-buttons-visible .side-band-button-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 60px;
+ flex-shrink: 0;
+}
+body.layout-modern.modern-buttons-visible .band-selector-button {
+ height: 28px;
+ border: none;
+ border-radius: 8px;
+ font-weight: bold;
+ font-size: 16px;
+ background-color: color-mix(in srgb, var(--color-4) 60%, transparent);
+ color: var(--color-main);
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+}
+body.layout-modern.modern-buttons-visible .band-selector-button:hover {
+ background-color: var(--color-5);
+ color: var(--color-main);
+}
+body.layout-modern.modern-buttons-visible .side-band-button-container .band-selector-button.active-band {
+ background-color: var(--color-4);
+ color: var(--color-main);
+}
+body.layout-modern.modern-buttons-visible #rt-container,
+body.layout-modern.modern-buttons-visible .am-bands-view-container {
+ flex-grow: 1;
+ min-width: 0;
+ background-color: var(--color-1-transparent);
+ backdrop-filter: blur(5px);
+ border-radius: 15px;
+ margin: 0 !important;
+ height: auto !important;
+ align-self: stretch;
+}
+body.layout-modern.modern-buttons-visible .am-bands-view-container {
+ display: grid;
+ grid-template-columns: 85px 1fr;
+ gap: 5px;
+ padding: 0 5px;
+}
+body.layout-modern.modern-buttons-visible .sw-grid-container {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ gap: 5px;
+ height: 100%;
+}
+body.layout-modern.modern-buttons-visible .am-view-button,
+body.layout-modern.modern-buttons-visible .sw-grid-button {
+ border: none;
+ border-radius: 8px;
+ background-color: var(--color-3);
+ color: var(--color-main);
+ font-weight: bold;
+ cursor: pointer;
+ font-size: 12px;
+ transition: all 0.2s ease-in-out;
+}
+body.layout-modern.modern-buttons-visible .am-view-button:hover,
+body.layout-modern.modern-buttons-visible .sw-grid-button:hover {
+ background-color: var(--color-4);
+}
+body.layout-modern.modern-buttons-visible .am-bands-view-container .am-view-button.active-band,
+body.layout-modern.modern-buttons-visible .am-bands-view-container .sw-grid-button.active-band {
+ background-color: var(--color-5);
+ color: var(--color-main);
+}
+body.layout-modern.modern-buttons-visible .am-view-button {
+ font-size: 14px;
+ height: 22px;
+ width: 60px;
+}
+body.layout-modern.modern-buttons-visible .sw-bands-fieldset,
+body.layout-modern.modern-buttons-visible .band-fieldset {
+ border: 1px solid var(--color-3);
+ border-bottom: none;
+ border-radius: 8px;
+ padding: 0px 5px 5px 5px;
+ margin: 0;
+ position: relative;
+}
+body.layout-modern.modern-buttons-visible .sw-bands-fieldset legend,
+body.layout-modern.modern-buttons-visible .band-fieldset legend {
+ color: var(--color-4);
+ font-weight: bold;
+ font-size: 11px;
+ width: auto;
+ margin: 0 auto;
+ padding: 0;
+}
+body.layout-modern.modern-buttons-visible .band-button-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ padding-top: 3px;
+ height: 100%;
+ box-sizing: border-box;
+}
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .tooltip,
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip {
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+}
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip {
+ line-height: 0;
+}
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltiptext {
+ visibility: hidden;
+ width: 180px;
+ position: absolute;
+ background-color: var(--color-2);
+ border: 2px solid var(--color-3);
+ color: var(--color-text);
+ text-align: center;
+ font-size: 14px;
+ border-radius: 15px;
+ padding: 8px;
+ z-index: 1000;
+ bottom: 110%;
+ left: 50%;
+ margin-left: -90px;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ line-height: normal;
+}
+body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip:hover .bs-tooltiptext {
+ visibility: visible;
+ opacity: 1;
+}
+
+/* B3: Loop button and frequency range for Modern Layout */
+body.layout-modern.loop-button-visible .loop-toggle-button {
+ position: absolute;
+ left: 6px;
+ bottom: 6px;
+ z-index: 5;
+ width: 34px;
+ height: auto;
+ min-height: 22px;
+ line-height: 1.2;
+ font-size: 11px;
+ font-weight: bold;
+ border: none;
+ border-radius: 8px;
+ background-color: var(--color-3);
+ color: var(--color-main);
+ cursor: pointer;
+ padding: 2px;
+}
+body.layout-modern.loop-button-visible .loop-toggle-button:hover {
+ background-color: var(--color-4);
+}
+body.layout-modern.loop-button-visible .loop-toggle-button.active {
+ background-color: var(--color-5) !important;
+ color: var(--color-main);
+}
+body.layout-modern.band-range-visible #band-range-container {
+ position: absolute;
+ bottom: 0px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--color-text);
+ opacity: 0.7;
+}
+body.layout-modern.band-range-visible .band-range-part {
+ cursor: pointer;
+}
+body.layout-modern.band-range-visible .band-range-part:hover {
+ opacity: 1;
+ text-decoration: underline;
+}
+
+/* B4: Mobile View for Modern Layout */
+@media (max-width: 768px) {
+ body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper {
+ display: block !important;
+ }
+ body.layout-modern.modern-buttons-visible .side-band-button-container,
+ body.layout-modern.modern-buttons-visible .am-bands-view-container {
+ display: none !important;
+ }
+ body.layout-modern #band-range-container,
+ body.layout-modern .loop-toggle-button {
+ display: none !important;
+ }
+ body.layout-modern.modern-buttons-visible #data-ant-container {
+ display: flex;
+ gap: 5px;
+ padding: 0 18px 10px 18px;
+ width: 100%;
+ box-sizing: border-box;
+ justify-content: center;
+ align-items: baseline;
+ }
+ body.layout-modern.modern-buttons-visible #data-ant-container::before,
+ body.layout-modern.modern-buttons-visible #data-ant-container::after {
+ content: '';
+ flex: 1;
+ }
+ body.layout-modern.modern-buttons-visible #data-ant-container > .dropdown,
+ body.layout-modern.modern-buttons-visible #mobile-band-selector-wrapper,
+ body.layout-modern.modern-buttons-visible #mobile-sw-band-selector-wrapper {
+ width: 45% !important;
+ flex: 0 1 auto !important;
+ transition: width 0.3s ease;
+ }
+ body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > .dropdown,
+ body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > #mobile-band-selector-wrapper,
+ body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > #mobile-sw-band-selector-wrapper {
+ width: 30% !important;
+ }
+ body.layout-modern.modern-buttons-visible #mobile-sw-band-selector-wrapper {
+ display: none;
+ justify-content: center;
+ align-items: center;
+ }
+ body.layout-modern.modern-buttons-visible #data-ant-container:has(#mobile-band-selector-wrapper:only-child)::before,
+ body.layout-modern.modern-buttons-visible #data-ant-container:has(#mobile-band-selector-wrapper:only-child)::after {
+ display: none;
+ }
+ body.layout-modern.modern-buttons-visible #mobile-band-selector-wrapper:only-child {
+ width: 50% !important;
+ margin: 0 auto;
+ }
+ body.layout-modern.modern-buttons-visible #mobile-band-selector,
+ body.layout-modern.modern-buttons-visible #mobile-sw-band-selector {
+ width: 100%;
+ height: 48px;
+ background-color: var(--color-4);
+ color: var(--color-main);
+ border: none;
+ border-radius: 0 0 15px 15px;
+ font-weight: normal;
+ font-size: 14px;
+ padding: 0 10px;
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url("data-image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 1em;
+ }
+}
+
+
+/* ========================================================================== */
+/* C: CLASSIC LAYOUT */
+/* ========================================================================== */
+
+/* C1: Basic structure for Classic Layout */
+body.layout-classic .band-selector-button {
+ background-color: rgba(0, 0, 0, 0.4);
+ color: var(--color-text);
+ border: 1px solid var(--color-2);
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-size: 10px;
+ font-weight: bold;
+ cursor: pointer;
+ opacity: 0.7;
+ transition: all 0.2s ease;
+ line-height: 1.4;
+}
+body.layout-classic #freq-container {
+ position: relative !important;
+ overflow: hidden;
+}
+body.layout-classic .plugin-top-container {
+ position: absolute;
+ top: 4px;
+ left: 6px;
+ z-index: 10;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+}
+body.layout-classic .main-bands-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+body.layout-classic .main-band-button.active-band,
+body.layout-classic .sw-band-button.active-band,
+body.layout-classic #loop-toggle-button.active {
+ background-color: var(--color-4);
+ color: var(--color-main);
+ opacity: 1;
+}
+body.layout-classic .sw-bands-container {
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ z-index: 9;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+body.layout-classic .sw-bands-grid {
+ display: grid;
+ gap: 2px;
+}
+body.layout-classic .sw-bands-top-wrapper {
+ grid-template-columns: repeat(3, 1fr);
+}
+body.layout-classic .sw-bands-bottom-wrapper {
+ grid-template-columns: repeat(2, 1fr);
+}
+body.layout-classic .sw-band-button {
+ padding: 1px 2px;
+ text-align: center;
+}
+body.layout-classic #band-range-container {
+ position: absolute;
+ bottom: 0px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 11px;
+ color: var(--color-text);
+ opacity: 0.6;
+ white-space: nowrap;
+}
+body.layout-classic .band-range-part {
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+body.layout-classic .band-range-part:hover {
+ opacity: 1;
+}
+body.layout-classic .range-separator {
+ opacity: 0.7;
+ pointer-events: none;
+}
+body.layout-classic #data-ant-container.classic-mobile-controls {
+ display: none;
+}
+
+/* C2: Mobile view for Classic Layout */
+@media (max-width: 768px) {
+ body.layout-classic .plugin-top-container,
+ body.layout-classic .sw-bands-container,
+ body.layout-classic #band-range-container {
+ display: none !important;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls {
+ display: flex !important;
+ gap: 5px;
+ padding: 0 18px 10px 18px;
+ width: 100%;
+ box-sizing: border-box;
+ justify-content: center;
+ align-items: baseline;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls::before,
+ body.layout-classic #data-ant-container.classic-mobile-controls::after {
+ content: '';
+ flex: 1;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls > .dropdown,
+ body.layout-classic #data-ant-container.classic-mobile-controls #mobile-band-selector-wrapper,
+ body.layout-classic #data-ant-container.classic-mobile-controls #mobile-sw-band-selector-wrapper {
+ width: 45% !important;
+ flex: 0 1 auto !important;
+ transition: width 0.3s ease;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > .dropdown,
+ body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > #mobile-band-selector-wrapper,
+ body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > #mobile-sw-band-selector-wrapper {
+ width: 30% !important;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls #mobile-sw-band-selector-wrapper {
+ display: none;
+ justify-content: center;
+ align-items: center;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls:has(#mobile-band-selector-wrapper:only-child)::before,
+ body.layout-classic #data-ant-container.classic-mobile-controls:has(#mobile-band-selector-wrapper:only-child)::after {
+ display: none;
+ }
+ body.layout-classic #data-ant-container.classic-mobile-controls #mobile-band-selector-wrapper:only-child {
+ width: 50% !important;
+ margin: 0 auto;
+ }
+ body.layout-classic #mobile-band-selector,
+ body.layout-classic #mobile-sw-band-selector {
+ width: 100%;
+ height: 48px;
+ background-color: var(--color-4);
+ color: var(--color-main);
+ border: none;
+ border-radius: 0 0 15px 15px;
+ font-weight: normal;
+ font-size: 14px;
+ padding: 0 10px;
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url("data-image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 1em;
+ }
+}
+
+/* ========================================================================== */
+/* FIX FOR BAND RANGE (Start <-> End) OVERLAP PÅ MOBIL / SMÅ SKJERMER */
+/* ========================================================================== */
+
+#band-range-container {
+ display: flex !important;
+ flex-wrap: nowrap !important;
+ white-space: nowrap !important;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+}
+
+.band-range-part {
+ white-space: nowrap !important;
+}
+
+@media (max-width: 600px) {
+ #band-range-container {
+ font-size: 10px !important;
+ }
+
+ .band-range-unit {
+ display: none !important;
+ }
+}
+@media (max-width: 768px) {
+ #magic-eye-wrapper { display: none !important; }
+ .magic-eye-panel-override { display: block !important; }
+ .magic-eye-text-wrapper { display: contents !important; }
+}
+.magic-eye-hidden { display: none !important; }
+
+.bw-option-selected {
+ background-color: var(--color-4) !important;
+ color: #000 !important;
+ font-weight: bold;
+}
+
+/* ========================================================================== */
+/* D: ANALOG SCALE HIDE FIX (THE 1x1 PIXEL HACK) */
+/* ========================================================================== */
+body.et-analog-active #sdr-graph {
+ display: block !important;
+ position: absolute !important;
+ top: 0 !important;
+ left: 0 !important;
+ z-index: 1 !important; /* Ligger skjult rett bak skalaen */
+ opacity: 1 !important;
+ pointer-events: none !important;
+}
+
+/* Skjuler ALT ANNET som krever ytelse, inkludert det originale runde signal-canvaset! */
+body.et-analog-active #signal-canvas,
+body.et-analog-active #Antenna,
+body.et-analog-active #containerRotator,
+body.et-analog-active #sdr-graph-button-container,
+body.et-analog-active .spectrum-graph-update-text,
+body.et-analog-active #mm-mpx-combo-flex,
+body.et-analog-active #mm-signal-analyzer-flex,
+body.et-analog-active #mm-scope-flex {
+ display: none !important;
+}
\ No newline at end of file
diff --git a/web/js/enchanced_tuning.js b/web/js/enchanced_tuning.js
new file mode 100644
index 0000000..7b0d130
--- /dev/null
+++ b/web/js/enchanced_tuning.js
@@ -0,0 +1,4257 @@
+// 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();
+ });
+})();
\ No newline at end of file