+
+

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 => ` +
+
${key}
+
+ Name + + Default Tune + + Low + + High + +
+
+`).join('')} + +

Shortwave Sub-Bands

+
+ ${Object.keys(pluginConfig.customSwBands).map(key => ` +
+
${key}
+
+ Default Tune + + Range + + - + +
+
+ `).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