Files
ewsdr/WebPageHtml.pas

1081 lines
86 KiB
ObjectPascal
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit WebPageHtml;
{
WebPageHtml.pas — Встроенный HTML/CSS/JS интерфейс веб-пульта HPSDR.
Содержит единственную функцию GetIndexHtml, которая возвращает полную
HTML-страницу (одним файлом) для отправки клиенту по HTTP GET /.
ИСПРАВЛЕНИЯ:
- Device-overlay (DEV): discovery/connect/saved-CRUD, паритет с десктоп-диалогом.
START теперь открывает overlay (если не подключены); STOP шлёт disconnect.
- Кнопка START/STOP добавлена в тулбар 1 (зелёная/красная)
- Кнопка RX/TX удалена из тулбара 2
- NR/NB/ANF: cur обновляется локально сразу при клике (иначе повторный
клик инвертировал уже отправленное значение)
- Opus: decodeFrame() правильный метод, async/await
- Opus: opQueue буферизует пакеты пока WASM не готов
- Opus: AudioContext создаётся только по user gesture (оверлей)
- SPANS: строятся из state.rate_presets (HPSDR/Pluto разные наборы)
- (Mode fix) modTglBtn теперь отображает ТЕКУЩИЙ активный режим
(LSB/USB, CWL/CWU, AM/SAM, FM), а не следующий. При клике
переключает на парный режим внутри группы.
- (Mode fix) cwBtn удалён — его роль выполняет modTglBtn при CW группе.
- (Mode fix) syncModGrp + updModTglLabel корректно синхронизируют UI
из server state: выпадающий список и кнопка отражают десктоп.
- (Mode fix) modGrpSel.onchange при смене группы сразу отправляет
команду mode с дефолтным режимом группы (LSB, CWL, FM, AM).
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
function GetIndexHtml: string;
implementation
function GetIndexHtml: string;
begin
Result :=
'<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<title>HPSDR Web Remote v2</title>' +
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
'<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400&family=Share+Tech+Mono&display=swap" rel="stylesheet">' +
'<style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
'body{background:#141414;color:#ccc;font-family:"Segoe UI",Tahoma,sans-serif;font-size:12px;overflow:hidden;height:100vh}' +
'.wrap{display:flex;flex-direction:column;height:100vh;gap:2px;padding:2px}' +
'@import url(''https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500&display=swap'');' +
'.tbar{display:flex;align-items:center;gap:5px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:3px;padding:2px 6px;flex-shrink:0;height:32px}' +
'.tsep{flex:1}' +
'button{background:linear-gradient(180deg,#404040,#2c2c2c);color:#ccc;border:1px solid #505050;border-radius:3px;padding:0 9px;font-size:11px;cursor:pointer;height:22px;white-space:nowrap;line-height:1;display:inline-flex;align-items:center;justify-content:center}' +
'button:hover{background:linear-gradient(180deg,#4c4c4c,#383838)}' +
'button.on{background:linear-gradient(180deg,#1e4d1e,#163216);border-color:#3d7a3d;color:#7fff7f}' +
'button.rx-tx{background:linear-gradient(180deg,#4d1e1e,#321616);border-color:#7a3d3d;color:#ff8080}' +
'button.mox-on{background:linear-gradient(180deg,#6d1e1e,#4d0a0a);border-color:#cc3333;color:#ffaaaa;font-weight:bold}' +
'select{background:#222;color:#bbb;border:1px solid #484848;border-radius:3px;padding:0 4px;font-size:11px;height:22px;cursor:pointer}' +
'.sl-g{display:flex;align-items:center;gap:3px}' +
'.sl-lb{font-size:10px;color:#777;white-space:nowrap}' +
'.sl-val{font-size:10px;color:#9ab;white-space:nowrap;min-width:2.5em;text-align:right}' +
'.sl-sep{color:#444;padding:0 2px;font-size:11px;align-self:center}' +
'input[type=range]{-webkit-appearance:none;height:3px;background:#3a3a3a;border-radius:2px;cursor:pointer;width:72px;outline:none}' +
'input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
'input[type=range]::-moz-range-thumb{width:11px;height:11px;border-radius:50%;background:#5a9ab0;border:1px solid #7ac;cursor:pointer}' +
'.vol-ic{font-size:13px;cursor:pointer;color:#7ab;user-select:none;line-height:1}' +
'.row2{height:74px;display:flex;align-items:center;gap:6px;background:#0c0c0c}' +
'#smeter{height:64px;width:340px;flex-shrink:0;border:0;border-radius:0;display:block}' +
'.vfo-mid{flex:1;display:flex;align-items:center;justify-content:center;gap:5px}' +
'.vbtn{width:38px;height:38px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;cursor:pointer;border-radius:3px;color:#888;border:1px solid #444;background:linear-gradient(180deg,#2a2a2a,#1e1e1e);padding:0}' +
'.vbtn.active{color:#7fff7f;border-color:#3d7a3d;background:linear-gradient(180deg,#1e3e1e,#142814)}' +
'.vfo-ro{font:400 44px "Orbitron","Consolas",monospace;letter-spacing:0.02em;color:#7fff7f;border:none;border-radius:0;padding:0 6px;cursor:pointer;user-select:none;height:62px;display:inline-flex;align-items:baseline;padding-top:10px;white-space:nowrap;overflow:hidden;background:transparent}' +
'.vfo-ro.dim{color:#1e501e;background:transparent}' +
'.vfo-ro .dg{display:inline-block;min-width:0.55em;font-size:44px;line-height:1}' +
'.vfo-ro .dg.lead{color:#2a3a2a}.vfo-ro.dim .dg.lead{color:#162016}' +
'.vfo-ro .sp{display:inline-block;color:#2a6030;min-width:0.3em;font-size:44px;line-height:1}' +
'.vfo-ro.dim .sp{color:#142014}' +
'.vfo-ro .dg.sel{background:rgba(127,255,127,.18);outline:1px solid rgba(127,255,127,.35);border-radius:2px}' +
'.vfo-hz{display:inline-flex;align-items:baseline}' +
'.vfo-hz .dg{display:inline-block;font-size:22px;line-height:1;min-width:0.55em;font-weight:400}' +
'.vfo-ro.dim .vfo-hz .dg{color:#1e501e}' +
'.swap-g{display:flex;gap:3px}' +
'.swap-g button{padding:0 7px;height:22px;font-size:10px}' +
'.right-g{display:flex;align-items:center;gap:4px;margin-left:auto}' +
'.right-g button,.right-g select{height:62px;font-size:12px}' +
'.spec-area{flex:1 1 auto;min-height:80px;position:relative;background:#050a05;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
'.spec-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.span-ov{position:absolute;top:4px;left:4px;display:flex;gap:3px;z-index:5}' +
'.sp-btn{background:rgba(15,25,15,0.78);color:#6a9a6a;border:1px solid rgba(60,100,60,0.5);border-radius:3px;padding:0 7px;font-size:10px;height:18px;cursor:pointer;backdrop-filter:blur(1px)}' +
'.sp-btn:hover{background:rgba(25,45,25,0.88);border-color:rgba(80,140,80,0.7);color:#9aca9a}' +
'.sp-btn.on{background:rgba(20,50,20,0.92);border-color:#4a9a4a;color:#aaffaa}' +
'.ruler-area{flex:0 0 24px;position:relative;background:#0c1210;border-left:1px solid #252525;border-right:1px solid #252525;overflow:hidden}' +
'.ruler-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.wf-area{flex:0 0 405px;position:relative;background:#000;border:1px solid #252525;border-radius:3px;overflow:hidden}' +
'.wf-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.sbar{flex:0 0 24px;display:grid;grid-template-columns:minmax(120px,170px) minmax(190px,280px) minmax(120px,160px) minmax(135px,170px) minmax(120px,185px) minmax(120px,190px) minmax(120px,180px) minmax(90px,1fr) minmax(82px,96px) minmax(92px,112px);align-items:stretch;background:#0e0e0e;border-top:1px solid #222;font:10px Courier New,monospace;color:#ccc;overflow-x:auto;overflow-y:hidden}' +
'.sb-cell{min-width:0;display:flex;align-items:center;padding:0 8px;border-left:1px solid #222;background:#181818;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.sb-cell:first-child{border-left:0}' +
'.sb-dim{color:#777}.sb-seq{color:#ffaa00}.sb-status{color:#90c090}.sb-time{justify-content:center;color:#bbb}.sb-lat{display:none}' +
'@media screen and (max-width:640px){.sbar{grid-template-columns:minmax(72px,1fr) minmax(104px,1.25fr) minmax(74px,1fr) minmax(78px,1fr);overflow:hidden}.sbar #sbBoard,.sbar #sbIP,.sbar #sbPLL,.sbar #sbRX,.sbar #sbTX,.sbar #sbLocal{display:none}.sb-cell{padding:0 5px;font-size:9px}}' +
'@media screen and (max-width:1300px) and (min-width:641px){' +
'.tbar{gap:3px;padding-left:4px;padding-right:4px;}' +
'button{padding:0 6px;font-size:10px;}' +
'select{padding:0 3px;font-size:10px;}' +
'.sl-g{gap:2px;}' +
'.sl-lb,.sl-val{font-size:9px;}' +
'.sl-val{min-width:2.1em;}' +
'.sl-sep{padding:0 1px;}' +
'input[type=range]{width:54px;}' +
'#smeter{width:260px;height:58px;}' +
'.row2{height:66px;gap:4px;}' +
'.vfo-mid{gap:3px;min-width:0;}' +
'.vbtn{width:30px;height:30px;font-size:11px;}' +
'.vfo-ro{font-size:32px;height:54px;padding-left:3px;padding-right:3px;padding-top:9px;}' +
'.vfo-ro .dg,.vfo-ro .sp{font-size:32px;}' +
'.vfo-hz .dg{font-size:16px;}' +
'.swap-g{gap:2px;}' +
'.swap-g button{padding:0 4px;font-size:9px;}' +
'.vfo-sp{width:4px!important;}' +
'.right-g{gap:3px;}' +
'.right-g button,.right-g select{height:54px;font-size:10px;}' +
'}' +
'@media screen and (max-width:640px){' +
'body{overflow:auto;height:auto;min-height:100vh;}' +
'.wrap{height:auto;min-height:100vh;padding:3px;}' +
// Тулбар 1: переносим группы по строкам, чтобы не было скрытых контролов
'.tbar:not(.row2){height:auto;min-height:32px;flex-wrap:wrap;align-items:center;overflow:visible;padding:4px;gap:4px;}' +
'.tbar:not(.row2)>*{flex-shrink:0;}' +
'.tbar:not(.row2) .tsep,.tbar:not(.row2) .sl-sep{display:none;}' +
'.sl-g{height:30px;}' +
'.sl-val{min-width:2.8em;}' +
// Крупнее зоны касания
'button{height:30px;}select{height:30px;}' +
// VFO-строка: стекаем smeter / vfo-mid / right-g каждый на своей строке
'.row2{height:auto;flex-wrap:wrap;padding:4px;gap:4px;}' +
'#smeter{width:100%;height:64px;}' +
'.vfo-mid{width:100%;display:grid;grid-template-columns:24px minmax(0,1fr) auto 24px minmax(0,1fr);gap:3px;align-items:center;}' +
'#vBtnA{grid-column:1;grid-row:1;}#vDispA{grid-column:2;grid-row:1;}' +
'.swap-g{grid-column:3;grid-row:1;justify-content:center;gap:2px;}' +
'#vBtnB{grid-column:4;grid-row:1;}#vDispB{grid-column:5;grid-row:1;}' +
'.vfo-ro{width:100%;font-size:16px;height:30px;padding:0 1px;padding-top:6px;justify-content:flex-start;}' +
'.vfo-ro .dg,.vfo-ro .sp{font-size:16px;}' +
'.vfo-hz .dg{font-size:8px;}' +
'.vbtn{width:24px;height:24px;font-size:10px;}' +
'.swap-g button{height:22px;padding:0 3px;font-size:8px;min-width:30px;}' +
'.vfo-sp{display:none;}' +
'.right-g{width:100%;margin-left:0;display:grid;grid-template-columns:58px minmax(0,1fr) 52px;gap:4px;}' +
'.right-g button,.right-g select{height:30px;font-size:11px;width:100%;min-width:0;}' +
'#bandSel{min-width:0;}' +
'.span-ov{right:4px;overflow-x:auto;scrollbar-width:none;}' +
'.span-ov::-webkit-scrollbar{display:none;}' +
'.sp-btn{flex:0 0 auto;height:22px;padding:0 6px;}' +
'.spec-area{min-height:130px;flex:0 0 30vh;}' +
'.ruler-area{flex:0 0 22px;}' +
// Водопад короче на телефоне
'.wf-area{flex:0 0 32vh;min-height:150px;}' +
// Слайдеры чуть уже
'input[type=range]{width:64px;}' +
// TX+MOX пинним в начало тулбара чтобы были видны без скролла
'#toneBtn{order:-3;}' +
'.tx-g{order:-2;}' +
'#moxBtn{order:-1;min-width:52px;height:30px;font-weight:bold;}' +
// iOS Safari: 100vh включает адресную строку — используем реальную высоту
'body,.wrap{min-height:-webkit-fill-available;}' +
'}' +
'</style></head>' +
'<body><div class="wrap">' +
// Тулбар 1: громкость, AGC, режимы, DSP кнопки, START/STOP
'<div class="tbar">' +
'<span class="vol-ic" id="muteIc" title="Mute">&#128266;</span>' +
'<div class="sl-g"><input type="range" id="volSl" min="0" max="100" value="70"><span class="sl-val" id="volVal">70</span></div>' +
'<span class="sl-sep">|</span>' +
'<div class="sl-g"><span class="sl-lb">RF</span><input type="range" id="rfSl" min="0" max="120" value="90"><span class="sl-val" id="rfVal">90dB</span></div>' +
'<span class="sl-sep">|</span>' +
'<button id="toneBtn">TONE</button>' +
'<span class="sl-sep">|</span>' +
'<div class="sl-g tx-g"><span class="sl-lb">TX</span><input type="range" id="txSl" min="0" max="100" value="50"><span class="sl-val" id="txVal">50</span></div>' +
// MIC gain — чисто web-side (Web Audio GainNode перед Opus-кодером). Сервер не знает.
'<div class="sl-g tx-g"><span class="sl-lb">MIC</span><input type="range" id="micSl" min="0" max="300" value="100"><span class="sl-val" id="micVal">100%</span></div>' +
'<button id="moxBtn">MOX</button>' +
'<span class="sl-sep">|</span>' +
'<div class="tsep"></div>' +
'<select id="agcSel">' +
'<option value="0">AGC-F</option><option value="1" selected>AGC-M</option>' +
'<option value="2">AGC-S</option><option value="3">AGC-L</option><option value="4">AGC-OFF</option>' +
'</select>' +
'<select id="modGrpSel">' +
'<option value="ssb" selected>SSB</option><option value="cw">CW</option>' +
'<option value="fm">FM</option><option value="am">AM</option>' +
'<option value="digi">DIGI</option><option value="dmr">DMR</option><option value="raw">FM RAW</option>' +
'</select>' +
'<select id="stepSel">' +
'<option value="10">10 Hz</option><option value="100" selected>100 Hz</option>' +
'<option value="1000">1 kHz</option><option value="10000">10 kHz</option>' +
'</select>' +
'<select id="attSel"><option value="0" selected>0 dB</option><option value="1">-10 dB</option><option value="2">-20 dB</option></select>' +
'<button id="nbBtn">NB</button>' +
'<button id="snbBtn">SNB</button>' +
'<button id="anfBtn">ANF</button>' +
'<button id="nrBtn">NR</button>' +
'<button id="bcnBtn" title="QO-100 beacon lock" style="display:none">BCN</button>' +
'<button id="rxMuteBtn" title="QO-100 RX self-monitor mute on TX" style="display:none">RX MUTE</button>' +
// Pluto RF-AGC (hw gain): кнопка → оверлей FAST/SLOW/HYBRID/MANUAL + ползунок (MANUAL). Видна только на Pluto.
'<button id="rfagcBtn" title="Pluto RF AGC" style="display:none">RF AGC</button>' +
'<span class="sl-sep">|</span>' +
'<button id="devBtn" title="Devices">DEV</button>' +
'<button id="startStopBtn" class="on">START</button>' +
'</div>' +
// Тулбар 2: S-метр, VFO A/B, диапазон, CW (кнопка TX/RX убрана)
'<div class="tbar row2">' +
'<canvas id="smeter" width="340" height="64"></canvas>' +
'<div class="vfo-mid">' +
'<button class="vbtn active" id="vBtnA">A</button>' +
'<div id="vDispA" class="vfo-ro"></div>' +
'<div class="swap-g">' +
'<button id="btnBA">B=A</button>' +
'<button id="btnSwap">SWAP</button>' +
'<button id="btnAB">A=B</button>' +
'</div>' +
'<span class="vfo-sp" style="display:inline-block;width:12px"></span>' +
'<button class="vbtn" id="vBtnB">B</button>' +
'<div id="vDispB" class="vfo-ro dim"></div>' +
'</div>' +
'<div class="right-g">' +
'<button id="modTglBtn">LSB</button>' +
'<select id="bandSel"></select>' +
'<button id="fnpBtn">FNP</button>' +
'</div>' +
'</div>' +
'<div class="spec-area"><canvas id="spec"></canvas><div class="span-ov" id="spanOv"></div></div>' +
'<div class="ruler-area"><canvas id="ruler"></canvas></div>' +
'<div class="wf-area"><canvas id="wf"></canvas></div>' +
'<div class="sbar">' +
'<span class="sb-cell sb-status" id="stEl">connecting...</span>' +
'<span class="sb-cell" id="sbBoard">Board --</span>' +
'<span class="sb-cell" id="sbIP">IP --</span>' +
'<span class="sb-cell" id="sbSupply">Supply --</span>' +
'<span class="sb-cell" id="sbPLL">PLL --</span>' +
'<span class="sb-cell sb-dim" id="sbRX">RX idle</span>' +
'<span class="sb-cell sb-dim" id="sbTX">TX idle</span>' +
'<span class="sb-cell sb-seq" id="sbSEQ">SEQ --</span>' +
'<span class="sb-cell sb-time" id="sbUTC">UTC --:--:--</span>' +
'<span class="sb-cell sb-time" id="sbLocal">Local --:--:--</span>' +
'<span class="sb-lat" id="latEl"></span>' +
'</div>' +
'</div>' +
'<script>' +
'const specCv=document.getElementById("spec"),wfCv=document.getElementById("wf"),' +
'rulerCv=document.getElementById("ruler"),smCv=document.getElementById("smeter");' +
'const sctx=specCv.getContext("2d"),wctx=wfCv.getContext("2d"),' +
'rctx=rulerCv.getContext("2d"),mctx=smCv.getContext("2d");' +
'const wfBuf=document.createElement("canvas"),wfBCtx=wfBuf.getContext("2d");' +
'const stEl=document.getElementById("stEl"),latEl=document.getElementById("latEl");' +
'let cur={},vfoA=14200000,vfoB=14200000,activeVfo="A";' +
'const FM_STEP_HZ=[6250,12500,20000,25000];' +
'const FM_STEP_LBL=["6.25k","12.5k","20k","25k"];' +
'let fmStepIdx=3,lastHFStep=100,inFMStep=false;' +
'let localCenter=14200000,localSpan=192000;' + // локальные копии — обновляются мгновенно при действии пользователя
'let dragging=false,dragX0=0,dragCen0=0,dragVfo0=0,dragMs=0,wheelActiveTs=0;' +
'let hoverX=-1,wTarget="",markerOn=false,markerX=0;' +
'let beaconArm=false;' + // одноразовое наведение после включения BCN (как десктоп)
'let specSm=[],wfAvg=[],wfHi=-50,wfLo=-120;' +
'let audioCtx=null,audioT=0;' +
'function audioKick(){if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:48000});audioT=audioCtx.currentTime+0.05;}if(audioCtx.state!=="running")audioCtx.resume();}' +
'const PAL=[0x000000,0x000003,0x000006,0x000009,0x00000C,0x00000F,0x000012,0x000015,0x000019,0x00001C,0x00001F,0x000022,0x000025,0x000028,0x00002B,0x00002F,0x000032,0x000035,0x000038,0x00003B,0x00003E,0x000041,0x000045,0x000048,0x00004B,0x00004E,0x000051,0x000054,0x000057,0x00005A,0x00005E,0x000061,0x000064,0x000067,0x00006A,0x00006D,0x000070,0x000074,0x000077,0x00007A,0x00007D,0x000080,0x000083,0x000086,0x00008A,0x00008D,0x000090,0x000093,0x000096,0x000099,0x00009C,0x0000A0,0x0002A2,0x0004A4,0x0006A7,0x0008A9,0x000AAC,0x000CAE,0x000EB1,0x0010B3,0x0012B6,0x0014B8,0x0017BB,0x0019BD,0x001BC0,0x001DC2,0x001FC5,0x0021C7,0x0023CA,0x0025CC,0x0027CF,0x0029D1,0x002BD4,0x002ED6,0x0030D9,0x0032DB,0x0034DE,0x0036E0,0x0038E3,0x003AE5,0x003CE8,0x003EEA,0x0040EC,0x0042EF,0x0045F1,0x0047F4,0x0049F6,0x004BF9,0x004DFB,0x004FFE,0x0052FE,0x0056FD,0x005AFC,0x005DFB,0x0061FA,0x0065F9,0x0068F8,0x006CF7,0x0070F6,0x0073F6,0x0077F5,0x007BF4,0x007EF3,0x0082F2,0x0085F1,0x0089F0,0x008DEF,0x0090EE,0x0094ED,0x0098EC,0x009BEC,0x009FEB,0x00A3EA,0x00A6E9,0x00AAE8,0x00AEE7,0x00B1E6,0x00B5E5,0x00B9E4,0x00BCE3,0x00C0E2,0x00C4E1,0x00C7E1,0x00CBE0,0x00CFDF,0x00D2DE,0x00D6DD,0x00DADC,0x00DCD9,0x00DDD3,0x00DECD,0x00DFC7,0x00E0C2,0x00E1BC,0x00E1B6,0x00E2B0,0x00E3AB,0x00E4A5,0x00E59F,0x00E699,0x00E794,0x00E88E,0x00E98' +
'8,0x00EA82,0x00EB7D,0x00EC77,0x00EC71,0x00ED6B,0x00EE66,0x00EF60,0x00F05A,0x00F154,0x00F24F,0x00F349,0x00F443,0x00F53D,0x00F638,0x00F632,0x00F72C,0x00F826,0x00F921,0x00FA1B,0x00FB15,0x00FC0F,0x00FD0A,0x00FE04,0x01FF00,0x08FF00,0x0EFF00,0x15FF00,0x1CFF00,0x22FF00,0x29FF00,0x30FF00,0x36FF00,0x3DFF00,0x44FF00,0x4AFF00,0x51FF00,0x58FF00,0x5FFF00,0x65FF00,0x6CFF00,0x72FF00,0x79FF00,0x80FF00,0x86FF00,0x8DFF00,0x94FF00,0x9BFF00,0xA1FF00,0xA8FF00,0xAEFF00,0xB5FF00,0xBCFF00,0xC2FF00,0xC9FF00,0xD0FF00,0xD6FF00,0xDDFF00,0xE4FF00,0xEAFF00,0xF1FF00,0xF8FF00,0xFFFF00,0xFFF900,0xFFF300,0xFFED00,0xFFE800,0xFFE200,0xFFDC00,0xFFD600,0xFFD100,0xFFCB00,0xFFC500,0xFFC000,0xFFBA00,0xFFB400,0xFFAE00,0xFFA900,0xFFA300,0xFF9D00,0xFF9800,0xFF9200,0xFF8C00,0xFF8600,0xFF8100,0xFF7B00,0xFF7500,0xFF7000,0xFF6A00,0xFF6400,0xFF5E00,0xFF5900,0xFF5300,0xFF5304,0xFF5C11,0xFF641D,0xFF6D2A,0xFF7536,0xFF7E43,0xFF864F,0xFF8F5C,0xFF9868,0xFFA075,0xFFA982,0xFFB18E,0xFFBA9A,0xFFC2A7,0xFFCBB3,0xFFD4C0,0xFFDCCC,0xFFE5D9,0xFFEDE6,0xFFF6F2,0xFFFFFF];' +
'const cl=(v,a,b)=>Math.max(a,Math.min(b,v));' +
'const q=id=>document.getElementById(id);' +
'function sw(id,on){const b=q(id);if(b)b.classList.toggle("on",!!on);}' +
'function sb(id,txt){const e=q(id);if(e){e.textContent=txt||"";e.title=txt||"";}}' +
'function fmtTime(d,utc){const p=n=>String(n).padStart(2,"0");return(utc?p(d.getUTCHours()):p(d.getHours()))+":"+p(utc?d.getUTCMinutes():d.getMinutes())+":"+p(utc?d.getUTCSeconds():d.getSeconds());}' +
'function updClock(){const d=new Date();sb("sbUTC","UTC "+fmtTime(d,true));sb("sbLocal","Local "+fmtTime(d,false));}' +
'function updStatusBar(){sb("stEl",cur.status_text||((cur.connected?"OK":"OFF")+" | "+(cur.running?"RUN":"STOP")));sb("sbBoard",cur.board_text||"Board --");sb("sbIP",cur.ip_text||"IP --");sb("sbSupply",cur.supply_text||"Supply --");sb("sbPLL",cur.beacon_visible?(cur.beacon_text||"BCN --"):(cur.pll_text||"PLL --"));sb("sbRX",cur.rx_text||"RX idle");sb("sbTX",cur.tx_text||"TX idle");sb("sbSEQ",cur.seq_text||"SEQ --");}' +
'updClock();setInterval(updClock,1000);' +
'function ws2(obj){if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}' +
'function cmd(c,v){ws2({cmd:c,on:v});}' +
'function updNrBtn(){const b=q("nrBtn");if(!b)return;const m=cur.nr_mode|0;b.textContent=(m===2)?"NR2":(m===3)?"NR3":(m===4)?"NR4":"NR";sw("nrBtn",m!==0);}' +
'function updNbBtn(){const b=q("nbBtn");if(!b)return;const m=cur.nb_mode|0;b.textContent=(m===2)?"NB2":"NB";sw("nbBtn",m!==0);}' +
'function updSnbBtn(){sw("snbBtn",!!cur.snb);}' +
'function updAnfBtn(){sw("anfBtn",!!cur.anf);}' +
'function updBcnBtn(){const b=q("bcnBtn");if(!b)return;b.style.display=cur.beacon_visible?"":"none";sw("bcnBtn",!!cur.beacon_on);}' +
'function updRxMute(){const b=q("rxMuteBtn");if(!b)return;b.style.display=cur.rx_mute_visible?"":"none";sw("rxMuteBtn",!!cur.rx_mute_on);}' +
'function rfagcModeName(m){m=m|0;return m===1?"FAST":m===2?"SLOW":m===3?"HYB":"MAN";}' +
'function updRfAgc(){const b=q("rfagcBtn");if(!b)return;b.style.display=cur.rf_agc_visible?"":"none";b.textContent="RF AGC "+rfagcModeName(cur.rx_gain_mode);if(rfagcEls)rfagcRefresh();}' +
'const MODE_N=["LSB","USB","DSB","CWL","CWU","FM","AM","SAM","DIGU","DIGL","WFM","DMR","FM RAW"];' +
'const M_LSB=MODE_N.indexOf("LSB"),M_USB=MODE_N.indexOf("USB"),M_DSB=MODE_N.indexOf("DSB"),M_CWL=MODE_N.indexOf("CWL"),M_CWU=MODE_N.indexOf("CWU"),M_FM=MODE_N.indexOf("FM"),M_AM=MODE_N.indexOf("AM"),M_SAM=MODE_N.indexOf("SAM"),M_DIGU=MODE_N.indexOf("DIGU"),M_DIGL=MODE_N.indexOf("DIGL"),M_WFM=MODE_N.indexOf("WFM"),M_DMR=MODE_N.indexOf("DMR"),M_FMRAW=MODE_N.indexOf("FM RAW");' +
'const GRP_MAP={ssb:[M_LSB,M_USB],cw:[M_CWL,M_CWU],fm:[M_FM,M_WFM],am:[M_AM,M_SAM],digi:[M_DIGU,M_DIGL],dmr:[M_DMR,M_DMR],raw:[M_FMRAW,M_FMRAW]};' +
'const MODE_TO_GRP=[];Object.entries(GRP_MAP).forEach(([g,ms])=>ms.forEach(m=>MODE_TO_GRP[m]=g));MODE_TO_GRP[M_DSB]="ssb";' +
'function modGrpFromMode(m){return MODE_TO_GRP[m]||"ssb";}' +
'function modGrp(){return q("modGrpSel").value||"ssb";}' +
'function modTglTarget(){const grp=GRP_MAP[modGrp()];const m=cur.mode||0;return(m===grp[0])?grp[1]:grp[0];}' +
'function updModTglLabel(){const b=q("modTglBtn");if(!b)return;const grp=GRP_MAP[modGrp()];const m=cur.mode||0;const show=(m===grp[0]||m===grp[1])?m:grp[0];b.textContent=MODE_N[show];}' +
'function syncModGrp(m){const g=MODE_TO_GRP[m]||"ssb";const s=q("modGrpSel");if(s&&s.value!==g)s.value=g;const f=q("fnpBtn");if(f)f.style.display=(m===M_DMR)?"none":"";updModTglLabel();}' +
'q("modGrpSel").onchange=function(){const grp=GRP_MAP[modGrp()];const defaultMode=grp[0];cur.mode=defaultMode;syncModGrp(defaultMode);pend("mode");ws2({cmd:"mode",mode:defaultMode});};' +
'q("modTglBtn").onclick=function(){audioKick();const next=modTglTarget();cur.mode=next;syncModGrp(next);pend("mode");ws2({cmd:"mode",mode:next});};' +
'q("agcSel").onchange=function(e){pend("agc");ws2({cmd:"agc",mode:+e.target.value});};' +
'q("attSel").onchange=function(e){pend("attn");ws2({cmd:"attn",idx:+e.target.value});};' +
// FNP — фильтровый оверлей
'var FNP_N={ssb:["5.0k","4.4k","3.8k","3.3k","2.9k","2.7k","2.4k","2.1k","1.8k","1.0k"],' +
'dsb:["16k","12k","10k","8.0k","6.6k","5.2k","4.0k","3.1k","2.9k","2.4k"],' +
'cw:["1.0k","800","600","500","400","250","150","100","50","25"],' +
'am:["20k","18k","16k","12k","10k","9.0k","8.0k","7.0k","6.0k","5.0k"],' +
'fm:["NFM","FM"],' +
'wfm:["200k","160k","120k"],' +
'dmr:["12.5k"],' +
'raw:["6.25k","10k","12.5k","20k","25k"],' +
'digi:["3.0k","2.7k","2.4k","2.1k","1.8k","1.5k","1.2k","900","600","300"]};' +
'var FNP_BW={ssb:[5000,4400,3800,3300,2900,2700,2400,2100,1800,1000],' +
'dsb:[16000,12000,10000,8000,6600,5200,4000,3100,2900,2400],' +
'cw:[1000,800,600,500,400,250,150,100,50,25],' +
'am:[20000,18000,16000,12000,10000,9000,8000,7000,6000,5000],' +
'fm:[11000,16000],' +
'wfm:[200000,160000,120000],' +
'dmr:[12500],' +
'raw:[6250,10000,12500,20000,25000],' +
'digi:[3000,2700,2400,2100,1800,1500,1200,900,600,300]};' +
'var fnpOv=null;' +
'function fnpGrp(){var m=cur.mode|0;return(m===M_CWL||m===M_CWU)?"cw":(m===M_FM)?"fm":(m===M_WFM)?"wfm":(m===M_DMR)?"dmr":(m===M_FMRAW)?"raw":(m===M_DIGU||m===M_DIGL)?"digi":(m===M_DSB)?"dsb":(m===M_AM||m===M_SAM)?"am":"ssb";}' +
'function closeFnp(){if(fnpOv){fnpOv.remove();fnpOv=null;}document.removeEventListener("click",fnpOutside);}' +
'function fnpOutside(e){if(fnpOv&&!fnpOv.contains(e.target)&&e.target!==q("fnpBtn"))closeFnp();}' +
'function showFnp(){' +
'if((cur.mode|0)===M_DMR)return;' +
'if(fnpOv){closeFnp();return;}' +
'var g=fnpGrp(),ns=FNP_N[g],bws=FNP_BW[g],cb=cur.filter_bw||2700;' +
'fnpOv=document.createElement("div");' +
'fnpOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;' +
'padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:260px;";' +
// заголовок
'var hd=document.createElement("div");' +
'hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
'var tl=document.createElement("span");' +
'tl.textContent="Filter — "+g.toUpperCase();' +
'tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
'var xb=document.createElement("button");' +
'xb.textContent="×";' +
'xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";' +
'xb.onclick=closeFnp;' +
'hd.appendChild(tl);hd.appendChild(xb);fnpOv.appendChild(hd);' +
// сетка кнопок
'var gd=document.createElement("div");' +
'var cols=(g==="fm")?2:5;' +
'gd.style.cssText="display:grid;grid-template-columns:repeat("+cols+",1fr);gap:5px;";' +
'ns.forEach(function(n,i){' +
'var b=document.createElement("button"),act=bws[i]===cb;' +
'b.textContent=n;' +
'b.style.cssText="background:"+(act?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)")+";' +
'color:"+(act?"#b0ffb0":"#7aaa7a")+";border:1px solid "+(act?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)")+";' +
'border-radius:4px;padding:5px 0;font-family:monospace;font-size:12px;cursor:pointer;' +
'transition:background 0.1s;";' +
'b.onmouseenter=function(){if(!act)this.style.background="rgba(20,50,20,0.95)";}; ' +
'b.onmouseleave=function(){if(!act)this.style.background="rgba(12,28,12,0.85)";}; ' +
'b.onclick=function(){cur.filter_bw=bws[i];pend("filter");ws2({cmd:"filter",bw:bws[i]});closeFnp();};' +
'gd.appendChild(b);' +
'});' +
'fnpOv.appendChild(gd);' +
'document.body.appendChild(fnpOv);' +
'setTimeout(function(){document.addEventListener("click",fnpOutside);},0);' +
'}' +
'q("fnpBtn").onclick=showFnp;' +
// ── Device discovery / connect / saved-CRUD overlay (паритет с десктоп-диалогом) ──
'var devOv=null,devList=null;' +
'function closeDev(){if(devOv){devOv.remove();devOv=null;devList=null;}}' +
'function devBtnS(bg,fg,bd){return "background:"+bg+";color:"+fg+";border:1px solid "+bd+";border-radius:4px;padding:4px 8px;font-family:monospace;font-size:11px;cursor:pointer;margin-left:4px;";}' +
'function renderDevList(){if(!devList)return;devList.innerHTML="";' +
'var devs=cur.devices||[];var conn=!!cur.connected;' +
'function mkRow(d,sIdx){' +
'var row=document.createElement("div");' +
'row.style.cssText="display:flex;align-items:center;justify-content:space-between;gap:6px;padding:5px 6px;border:1px solid rgba(55,95,55,0.4);border-radius:4px;margin-bottom:4px;background:rgba(12,28,12,0.7);";' +
'var lbl=document.createElement("span");' +
'lbl.textContent=d.saved?((d.autostart?"★ ":"")+(d.name||"HPSDR")+" "+d.ip):d.name;' +
'lbl.style.cssText="color:#9cc99c;font-family:monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";' +
'var btns=document.createElement("span");btns.style.cssText="display:flex;flex:none;";' +
'var cb=document.createElement("button");cb.textContent="Connect";' +
'cb.style.cssText=devBtnS("rgba(25,70,25,0.95)","#b0ffb0","rgba(120,220,120,0.7)");' +
'cb.onclick=function(){ws2({cmd:"connect",ip:d.ip});closeDev();};if(conn)cb.disabled=true;' +
'btns.appendChild(cb);' +
'if(d.saved){' +
'var ab=document.createElement("button");ab.textContent="★";ab.title="Auto-start";' +
'ab.style.cssText=devBtnS("rgba(12,28,12,0.85)",d.autostart?"#ffd060":"#7aaa7a","rgba(55,95,55,0.45)");' +
'ab.onclick=function(){ws2({cmd:"dev_autostart",idx:sIdx});};' +
'var rb=document.createElement("button");rb.textContent="✕";rb.title="Remove";' +
'rb.style.cssText=devBtnS("rgba(40,12,12,0.85)","#cc8080","rgba(95,55,55,0.45)");' +
'rb.onclick=function(){ws2({cmd:"dev_remove",idx:sIdx});};' +
'btns.appendChild(ab);btns.appendChild(rb);' +
'}else{' +
'var sv=document.createElement("button");sv.textContent="Save";' +
'sv.style.cssText=devBtnS("rgba(12,28,12,0.85)","#7aaa7a","rgba(55,95,55,0.45)");' +
'sv.onclick=function(){ws2({cmd:"dev_add",name:d.name||"",ip:d.ip});};' +
'btns.appendChild(sv);' +
'}' +
'row.appendChild(lbl);row.appendChild(btns);return row;' +
'}' +
'var si=-1,hasSaved=false,hasFound=false;' +
'devs.forEach(function(d){if(!d.saved)return;si++;' +
'if(!hasSaved){var h=document.createElement("div");h.textContent="Saved";h.style.cssText="color:#6a9a6a;font-size:10px;margin:2px 0 4px;letter-spacing:1px;";devList.appendChild(h);hasSaved=true;}' +
'devList.appendChild(mkRow(d,si));});' +
'devs.forEach(function(d){if(d.saved)return;' +
'if(!hasFound){var h=document.createElement("div");h.textContent="Discovered";h.style.cssText="color:#6a9a6a;font-size:10px;margin:8px 0 4px;letter-spacing:1px;";devList.appendChild(h);hasFound=true;}' +
'devList.appendChild(mkRow(d,-1));});' +
'if(!hasSaved&&!hasFound){var e=document.createElement("div");e.textContent="No devices — Discover or add manually";e.style.cssText="color:#6a8a6a;font-size:11px;font-style:italic;padding:6px 2px;";devList.appendChild(e);}' +
'}' +
'function showDev(){' +
'if(devOv){closeDev();return;}' +
'devOv=document.createElement("div");' +
'devOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(6,14,6,0.95);border:1px solid rgba(70,120,70,0.65);border-radius:8px;padding:14px 16px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);width:340px;max-width:92vw;max-height:80vh;overflow:auto;";' +
'var hd=document.createElement("div");hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
'var tl=document.createElement("span");tl.textContent="Devices";tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
'var xb=document.createElement("button");xb.textContent="×";xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";xb.onclick=closeDev;' +
'hd.appendChild(tl);hd.appendChild(xb);devOv.appendChild(hd);' +
'var ar=document.createElement("div");ar.style.cssText="display:flex;gap:6px;margin-bottom:10px;";' +
'var db=document.createElement("button");db.textContent="Discover";db.style.cssText=devBtnS("rgba(25,55,80,0.95)","#a0d0ff","rgba(90,150,210,0.7)")+"margin-left:0;flex:1;";db.onclick=function(){ws2({cmd:"discover"});};ar.appendChild(db);' +
'var dc=document.createElement("button");dc.textContent="Disconnect";dc.style.cssText=devBtnS("rgba(60,20,20,0.9)","#e0a0a0","rgba(150,80,80,0.6)")+"margin-left:0;flex:1;";dc.onclick=function(){ws2({cmd:"disconnect"});closeDev();};if(!cur.connected)dc.disabled=true;ar.appendChild(dc);' +
'devOv.appendChild(ar);' +
'devList=document.createElement("div");devOv.appendChild(devList);' +
'var mf=document.createElement("div");mf.style.cssText="margin-top:12px;border-top:1px solid rgba(55,95,55,0.35);padding-top:10px;display:flex;flex-direction:column;gap:6px;";' +
'var inS="background:rgba(0,0,0,0.4);border:1px solid rgba(55,95,55,0.5);border-radius:4px;color:#b0e0b0;font-family:monospace;font-size:11px;padding:5px 6px;width:100%;box-sizing:border-box;";' +
'var nm=document.createElement("input");nm.placeholder="Name (optional)";nm.style.cssText=inS;' +
'var ip=document.createElement("input");ip.placeholder="IP address";ip.style.cssText=inS;' +
'mf.appendChild(nm);mf.appendChild(ip);' +
'var mb=document.createElement("div");mb.style.cssText="display:flex;gap:6px;";' +
'var cob=document.createElement("button");cob.textContent="Connect";cob.style.cssText=devBtnS("rgba(25,70,25,0.95)","#b0ffb0","rgba(120,220,120,0.7)")+"margin-left:0;flex:1;";cob.onclick=function(){var v=ip.value.trim();if(v){ws2({cmd:"connect",ip:v});closeDev();}};' +
'var svb=document.createElement("button");svb.textContent="Save";svb.style.cssText=devBtnS("rgba(12,28,12,0.85)","#7aaa7a","rgba(55,95,55,0.45)")+"margin-left:0;flex:1;";svb.onclick=function(){var v=ip.value.trim();if(v){ws2({cmd:"dev_add",name:nm.value.trim(),ip:v});nm.value="";ip.value="";}};' +
'mb.appendChild(cob);mb.appendChild(svb);mf.appendChild(mb);devOv.appendChild(mf);' +
'renderDevList();document.body.appendChild(devOv);' +
'}' +
'q("devBtn").onclick=showDev;' +
'q("volSl").oninput=function(e){ws2({cmd:"volume",v:+e.target.value});var v=q("volVal");if(v)v.textContent=e.target.value;};' +
'q("rfSl").oninput=function(e){ws2({cmd:"agctop",db:+e.target.value});var v=q("rfVal");if(v)v.textContent=e.target.value+"dB";};' +
'q("txSl").oninput=function(e){var v=+e.target.value;var lbl=q("txVal");if(lbl)lbl.textContent=v;pend("drive");ws2({cmd:"drive",v:v});};' +
// MIC gain — только клиент, GainNode применяется к захваченному микрофону до Opus.
// Сохраняется в localStorage. 100 = 1.0× (нейтрально), 300 = 3.0× (макс).
'let micGainVal=parseInt(localStorage.getItem("micGain")||"100",10);if(!isFinite(micGainVal))micGainVal=100;' +
'(function(){var s=q("micSl"),l=q("micVal");if(!s)return;s.value=micGainVal;if(l)l.textContent=micGainVal+"%";s.oninput=function(e){var v=+e.target.value;micGainVal=v;if(l)l.textContent=v+"%";if(txMicGain)txMicGain.gain.value=v/100;try{localStorage.setItem("micGain",String(v));}catch(ex){}};})();' +
'function updMoxBtn(tx){var b=q("moxBtn");if(!b)return;b.textContent=tx?"TX":"MOX";b.classList.toggle("mox-on",!!tx);}' +
// TX-mic пре-инициализация в user-gesture: AudioContext СОЗДАЁТСЯ И РЕЗЮМИТСЯ в самом click,
// ДО любого await — иначе на Android (особенно Honor/HarmonyOS) после await getUserMedia
// user-gesture считается истёкшим и AudioContext навсегда остаётся в suspended.
'q("moxBtn").onclick=function(){audioKick();cur.transmitting=!cur.transmitting;pend("mox");updMoxBtn(cur.transmitting);if(cur.transmitting){if(!txMicACtx){try{txMicACtx=new AudioContext({sampleRate:48000});}catch(e){}}if(txMicACtx&&txMicACtx.state!=="running")txMicACtx.resume();startTXMic();}cmd("set_mox",cur.transmitting);};' +
'function updToneBtn(t){var b=q("toneBtn");if(!b)return;b.classList.toggle("on",!!t);}' +
'q("toneBtn").onclick=function(){audioKick();cur.tuning=!cur.tuning;pend("tun");updToneBtn(cur.tuning);cmd("set_tun",cur.tuning);};' +
// DSP кнопки — cur обновляется локально сразу, не ждём ответа сервера
'q("nrBtn").onclick=function(){cur.nr_mode=((cur.nr_mode|0)+1)%5;cur.nr=cur.nr_mode!==0;updNrBtn();pend("nr");ws2({cmd:"set_nr_mode",mode:cur.nr_mode});};' +
'q("nbBtn").onclick=function(){cur.nb_mode=((cur.nb_mode|0)+1)%3;cur.nb=cur.nb_mode!==0;updNbBtn();pend("nb");ws2({cmd:"set_nb_mode",mode:cur.nb_mode});};' +
'q("bcnBtn").onclick=function(){cur.beacon_on=!cur.beacon_on;beaconArm=cur.beacon_on;updBcnBtn();pend("beacon");cmd("set_beacon",cur.beacon_on);};' +
'q("rxMuteBtn").onclick=function(){cur.rx_mute_on=!cur.rx_mute_on;updRxMute();pend("rxmute");cmd("set_rx_mute",cur.rx_mute_on);};' +
// RF-AGC оверлей: кнопка RF AGC → попап FAST/SLOW/HYBRID/MANUAL + ползунок (активен в MANUAL).
'var rfagcOv=null,rfagcEls=null;' +
'var RFAGC_MODES=[["FAST",1],["SLOW",2],["HYBRID",3],["MANUAL",0]];' +
'function closeRfagc(){if(rfagcOv){rfagcOv.remove();rfagcOv=null;rfagcEls=null;}document.removeEventListener("click",rfagcOutside);}' +
'function rfagcOutside(e){if(rfagcOv&&!rfagcOv.contains(e.target)&&e.target!==q("rfagcBtn"))closeRfagc();}' +
'function rfagcRefresh(){if(!rfagcEls)return;var m=cur.rx_gain_mode|0,g=cur.rx_gain_db|0;' +
'rfagcEls.btns.forEach(function(o){var a=o.mode===m;o.el.style.background=a?"rgba(25,70,25,0.95)":"rgba(12,28,12,0.85)";o.el.style.color=a?"#b0ffb0":"#7aaa7a";o.el.style.borderColor=a?"rgba(120,220,120,0.7)":"rgba(55,95,55,0.45)";});' +
'if(!rfagcEls.sl.matches(":active"))rfagcEls.sl.value=g;rfagcEls.sl.disabled=(m!==0);rfagcEls.sl.style.opacity=(m!==0)?"0.4":"1";rfagcEls.val.textContent=g+"dB";}' +
'function showRfagc(){' +
'if(rfagcOv){closeRfagc();return;}' +
'rfagcOv=document.createElement("div");' +
'rfagcOv.style.cssText="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(6,14,6,0.93);border:1px solid rgba(70,120,70,0.65);border-radius:8px;padding:12px 14px;z-index:2000;backdrop-filter:blur(6px);box-shadow:0 4px 24px rgba(0,0,0,0.7);min-width:240px;";' +
'var hd=document.createElement("div");hd.style.cssText="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;";' +
'var tl=document.createElement("span");tl.textContent="RF AGC";tl.style.cssText="color:#80b880;font-family:monospace;font-size:12px;letter-spacing:1px;";' +
'var xb=document.createElement("button");xb.textContent="×";xb.style.cssText="background:none;border:none;color:#6a9a6a;font-size:16px;cursor:pointer;padding:0 2px;line-height:1;";xb.onclick=closeRfagc;' +
'hd.appendChild(tl);hd.appendChild(xb);rfagcOv.appendChild(hd);' +
'var gd=document.createElement("div");gd.style.cssText="display:grid;grid-template-columns:repeat(2,1fr);gap:5px;margin-bottom:10px;";' +
'var btns=[];' +
'RFAGC_MODES.forEach(function(pm){var b=document.createElement("button");b.textContent=pm[0];b.style.cssText="border:1px solid;border-radius:4px;padding:6px 0;font-family:monospace;font-size:12px;cursor:pointer;transition:background 0.1s;";' +
'b.onclick=function(){cur.rx_gain_mode=pm[1];pend("rxgain");ws2({cmd:"set_rx_gain_mode",mode:pm[1]});rfagcRefresh();updRfAgc();};' +
'gd.appendChild(b);btns.push({el:b,mode:pm[1]});});' +
'rfagcOv.appendChild(gd);' +
'var sr=document.createElement("div");sr.style.cssText="display:flex;align-items:center;gap:8px;";' +
'var lb=document.createElement("span");lb.textContent="GAIN";lb.style.cssText="color:#7aaa7a;font-family:monospace;font-size:11px;";' +
'var sl=document.createElement("input");sl.type="range";sl.min="0";sl.max="73";sl.value=String(cur.rx_gain_db|0);sl.style.cssText="flex:1;";' +
'var vv=document.createElement("span");vv.style.cssText="color:#9c9;font-family:monospace;font-size:11px;min-width:34px;text-align:right;";vv.textContent=(cur.rx_gain_db|0)+"dB";' +
'sl.oninput=function(e){var v=+e.target.value;cur.rx_gain_db=v;cur.rx_gain_mode=0;vv.textContent=v+"dB";pend("rxgain");ws2({cmd:"set_rx_gain",db:v});rfagcRefresh();updRfAgc();};' +
'sr.appendChild(lb);sr.appendChild(sl);sr.appendChild(vv);rfagcOv.appendChild(sr);' +
'rfagcEls={btns:btns,sl:sl,val:vv};rfagcRefresh();' +
'document.body.appendChild(rfagcOv);' +
'setTimeout(function(){document.addEventListener("click",rfagcOutside);},0);' +
'}' +
'q("rfagcBtn").onclick=showRfagc;' +
'q("snbBtn").onclick=function(){cur.snb=!cur.snb;updSnbBtn();pend("snb");cmd("set_snb",cur.snb);};' +
'q("anfBtn").onclick=function(){cur.anf=!cur.anf;updAnfBtn();pend("anf");cmd("set_anf",cur.anf);};' +
// START/STOP
'function updRunBtn(r){var b=q("startStopBtn");if(!b)return;b.textContent=r?"STOP":"START";b.classList.toggle("rx-tx",!!r);b.classList.toggle("on",!r);}' +
// Подключённы → STOP (disconnect). Иначе → device-overlay (выбор/подключение).
// set_run:true больше не открывает устройство (web стартует через connect).
'q("startStopBtn").onclick=function(){audioKick();if(cur.connected||cur.running){ws2({cmd:"disconnect"});cur.running=false;pend("run");updRunBtn(false);}else{showDev();}};' +
'function updMuteIc(){var mi=q("muteIc");if(mi)mi.textContent=cur.mute?"\uD83D\uDD07":"\uD83D\uDD0A";}' +
'q("muteIc").onclick=()=>{cur.mute=!cur.mute;updMuteIc();pend("mute");cmd("set_mute",cur.mute);};' +
'let stA=0,stAmem=100,stB=0,stBmem=100;' +
'let freqMhzDigits=3,freqMaxHz=999999999;' +
'let freqMin=30000,freqMax=60000000;' + // диапазон бэкенда (из state.freq_min/max)
'function fmtVfo(hz,step){' +
'const N=freqMhzDigits+6;' +
'const s=String(Math.max(0,Math.round(hz||0))).padStart(N,"0");' +
'let h="",seenNZ=false;' +
'for(let i=0;i<freqMhzDigits;i++){' +
'const st=Math.pow(10,freqMhzDigits+5-i);' +
'if(s[i]!=="0")seenNZ=true;' +
'const lead=(!seenNZ)?" lead":"";' +
'const sel=(step>0&&st===step)?" sel":"";' +
'h+="<span class=\"dg"+lead+sel+"\" data-s=\""+st+"\">"+s[i]+"</span>";}' +
'h+="<span class=\"sp\">.</span>";' +
'for(let i=freqMhzDigits;i<freqMhzDigits+3;i++){' +
'const st=Math.pow(10,freqMhzDigits+5-i);' +
'const sel=(step>0&&st===step)?" sel":"";' +
'h+="<span class=\"dg"+sel+"\" data-s=\""+st+"\">"+s[i]+"</span>";}' +
'h+="<span class=\"sp\">.</span><span class=\"vfo-hz\">";' +
'for(let i=freqMhzDigits+3;i<N;i++){' +
'const st=Math.pow(10,freqMhzDigits+5-i);' +
'const sel=(step>0&&st===step)?" sel":"";' +
'h+="<span class=\"dg"+sel+"\" data-s=\""+st+"\">"+s[i]+"</span>";}' +
'h+="</span>";return h;}' +
'function renderVfos(){q("vDispA").innerHTML=fmtVfo(vfoA,stA);q("vDispB").innerHTML=fmtVfo(vfoB,stB);}' +
'function bindVfo(dispId,getHz,setHz,getSt,setSt,getMem,setMem,onSend){' +
'const el=q(dispId);' +
'el.addEventListener("mousemove",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);renderVfos();}});' +
'el.addEventListener("click",e=>{const t=e.target,s=Number(t?.dataset?.s||0);if(s>0){setSt(s);setMem(s);}});' +
'el.addEventListener("mouseleave",()=>{if(getSt()!==0){setSt(0);renderVfos();}});' +
'el.addEventListener("wheel",e=>{e.preventDefault();e.stopPropagation();audioKick();' +
'const dir=e.deltaY<0?1:-1,st=getSt()||getMem()||100;' +
'const nf=cl(Math.round(getHz())+st*dir,30000,freqMaxHz);' +
'setHz(nf);onSend(nf);},{passive:false});' +
'el.addEventListener("dblclick",()=>{' +
'const s=prompt("Set Hz",String(Math.round(getHz())));if(!s)return;' +
'const f=parseInt(s.replace(/[^0-9]/g,""),10);if(!isFinite(f))return;' +
'const nf=cl(f,30000,freqMaxHz);setHz(nf);renderVfos();onSend(nf);' +
'});}' +
'bindVfo("vDispA",()=>vfoA,v=>{vfoA=v;},()=>stA,v=>{stA=v;},()=>stAmem,v=>{stAmem=v;},' +
'function(nf){renderVfos();pend("freq");if(activeVfo==="A"){localCenter=nf;ws2({cmd:"freq",hz:nf});}else ws2({cmd:"freq_b",hz:nf});});' +
'bindVfo("vDispB",()=>vfoB,v=>{vfoB=v;},()=>stB,v=>{stB=v;},()=>stBmem,v=>{stBmem=v;},' +
'function(nf){renderVfos();pend("freq");if(activeVfo==="B"){localCenter=nf;ws2({cmd:"freq",hz:nf});}else ws2({cmd:"freq_b",hz:nf});});' +
'function selVfo(w){' +
'activeVfo=w;' +
'q("vBtnA").classList.toggle("active",w==="A");' +
'q("vBtnB").classList.toggle("active",w==="B");' +
'q("vDispA").classList.toggle("dim",w!=="A");' +
'q("vDispB").classList.toggle("dim",w!=="B");' +
// Notify server: switch active VFO (server calls ActivateVfo which retunes DDC)
'ws2({cmd:"set_active_vfo",idx:w==="A"?0:1});' +
'}' +
'q("vBtnA").onclick=()=>{if(activeVfo!=="A")selVfo("A");};' +
'q("vBtnB").onclick=()=>{if(activeVfo!=="B")selVfo("B");};' +
// B=A: copy A freq into B
'q("btnBA").onclick=()=>{vfoB=vfoA;renderVfos();pend("freq");ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// A=B: copy B freq into A — always freq_a (sets VFO A regardless of active VFO)
'q("btnAB").onclick=()=>{vfoA=vfoB;renderVfos();pend("freq");ws2({cmd:"freq_a",hz:Math.round(vfoA)});};' +
// SWAP — freq_a always sets VFO A, freq_b always sets VFO B
'q("btnSwap").onclick=()=>{const t=vfoA;vfoA=vfoB;vfoB=t;renderVfos();pend("freq");' +
'ws2({cmd:"freq_a",hz:Math.round(vfoA)});ws2({cmd:"freq_b",hz:Math.round(vfoB)});};' +
// SPAN/sample-rate кнопки: строятся из state.rate_presets (HPSDR и Pluto
// имеют разные наборы; единый источник — backend caps в контроллере).
'let SPANS=[48000,96000,192000,384000,768000,1536000];' +
'function fmtRate(hz){return Math.round(hz/1000)+"K";}' +
'function buildSpanButtons(){const c=q("spanOv");if(!c)return;c.innerHTML="";SPANS.forEach((hz,i)=>{const b=document.createElement("button");b.className="sp-btn";b.id="sb"+i;b.textContent=fmtRate(hz);b.onclick=()=>ws2({cmd:"span",hz});c.appendChild(b);});updSpan(localSpan);}' +
'function updSpan(s){SPANS.forEach((hz,i)=>{const b=q("sb"+i);if(b)b.classList.toggle("on",hz===s);});}' +
'buildSpanButtons();' +
// BAND_N/BAND_F строятся из state.bands (HPSDR HF / Pluto VHF-UHF); ниже —
// bootstrap-значения до первого state. Единый источник — backend в контроллере.
'let BAND_N=["160m","80m","60m","40m","30m","20m","17m","15m","12m","10m","6m"];' +
'let BAND_F=[1840000,3700000,5357000,7100000,10120000,14200000,18130000,21300000,24940000,28400000,50150000];' +
// XVTR-band list, обновляется из state ("xvtr_bands":[{idx,name},...]).
// Активное значение — "xvtr_current" (-1 = HF). В <select> используем
// значения "h<n>" для HF и "x<idx>" для XVTR, чтобы различать.
'let XVTR_BANDS=[];let XVTR_CUR=-1;' +
'function rebuildBandSel(){const s=q("bandSel");if(!s)return;' +
'const saved=s.value;s.innerHTML="";' +
'BAND_N.forEach((n,i)=>{if(!n)return;const o=document.createElement("option");o.value="h"+i;o.textContent=n;s.appendChild(o);});' +
'XVTR_BANDS.forEach(xb=>{const o=document.createElement("option");o.value="x"+xb.idx;o.textContent=xb.name||("X"+(xb.idx+1));s.appendChild(o);});' +
// Восстановить выбор: предпочитаем "current XVTR"; иначе сохраним прежнее
'if(XVTR_CUR>=0)s.value="x"+XVTR_CUR; else if(saved)s.value=saved;' +
'}' +
'(function(){const s=q("bandSel");BAND_N.forEach((n,i)=>{if(!n)return;const o=document.createElement("option");o.value="h"+i;o.textContent=n;s.appendChild(o);});' +
's.onchange=()=>{const v=s.value;if(!v)return;if(v[0]==="x")ws2({cmd:"xvtr_band",idx:+v.slice(1)});else if(v[0]==="h")ws2({cmd:"band",idx:+v.slice(1)});};' +
'})();' +
'function updBand(vfo){if(XVTR_CUR>=0){q("bandSel").value="x"+XVTR_CUR;return;}let bi=0,bd=1e15;BAND_F.forEach((f,i)=>{if(!BAND_N[i])return;const d=Math.abs((vfo||0)-f);if(d<bd){bd=d;bi=i;}});q("bandSel").value="h"+bi;}' +
'let smAvg=-130,smPeak=-130,smMin=-130;' +
'function dbToX(db,bx,bw){const DB_MIN=-127,DB_MAX=-13;return Math.max(bx,Math.min(bx+bw,Math.round(bx+(db-DB_MIN)/(DB_MAX-DB_MIN)*bw)));}' +
'function paintSM(dbm){' +
'if(cur.transmitting||cur.tuning){paintTXMeter();return;}' +
'const w=smCv.clientWidth||340,h=smCv.clientHeight||64;' +
'const AVG_ALPHA=0.40,ZONE_DB=6,PEAK_BASE=0.12,MIN_BASE=0.12,ACCEL=0.030,MAX_A=0.92;' +
'const DBM_MARKS=[-120,-100,-80,-60,-40,-20];' +
'const DBM_LABELS=["-120","-100","-80","-60","-40","-20"];' +
'const S_MARKS_DBM=[-121,-109,-97,-85,-73,-53,-33,-13];' +
'const S_MARKS_LBL=["S1","S3","S5","S7","S9","+20","+40","+60"];' +
'const TICK_LONG=5,TICK_MED=3;' +
'smAvg=smAvg*(1-AVG_ALPHA)+dbm*AVG_ALPHA;' +
'const pTarget=Math.max(dbm,smAvg+ZONE_DB),mTarget=Math.min(dbm,smAvg-ZONE_DB);' +
'const pA=Math.min(MAX_A,PEAK_BASE+Math.abs(pTarget-smPeak)*ACCEL);' +
'const mA=Math.min(MAX_A,MIN_BASE+Math.abs(mTarget-smMin)*ACCEL);' +
'smPeak+=pA*(pTarget-smPeak);smMin+=mA*(mTarget-smMin);' +
'let sLbl;if(dbm>=-73){let over=Math.round(dbm+73);if(over<5)sLbl="S9";else{over=Math.floor((over+5)/10)*10;if(over===0)over=10;sLbl="S9+"+over;}}' +
'else if(dbm<=-121){sLbl="S1";}else{sLbl="S1";for(let i=1;i<=8;i++){if(dbm>=(-121+(i-1)*6))sLbl="S"+i;}}' +
'mctx.font="bold 12px Courier New";const lblDbm=dbm.toFixed(1),twDbm=mctx.measureText(lblDbm).width,maxTwDbm=mctx.measureText("-120.0").width;' +
'mctx.font="8px Courier New";const labelNeed=Math.max(maxTwDbm+2+mctx.measureText("dBm ").width,mctx.measureText("S9+60").width)+8;' +
'const leftInfo=Math.max(62,labelNeed+4),BX=leftInfo,BW=Math.max(10,w-leftInfo-8),lblZoneL=BX-labelNeed;' +
'const YT=Math.round(h*44/100),YB=Math.round(h*58/100),barH=YB-YT;' +
'const S9X=dbToX(-73,BX,BW),OVRX=dbToX(-43,BX,BW);' +
'mctx.clearRect(0,0,w,h);' +
'mctx.fillStyle="#001F09";mctx.fillRect(BX,YT,S9X-BX,barH);' +
'mctx.fillStyle="#001F09";mctx.fillRect(S9X,YT,OVRX-S9X,barH);' +
'mctx.fillStyle="#281509";mctx.fillRect(OVRX,YT,BX+BW-OVRX,barH);' +
'const barEnd=dbToX(dbm,BX,BW);' +
'if(barEnd>BX){if(barEnd<=S9X){mctx.fillStyle="#001F09";mctx.fillRect(BX,YT+1,barEnd-BX,barH-2);}' +
'else{mctx.fillStyle="#001F09";mctx.fillRect(BX,YT+1,S9X-BX,barH-2);mctx.fillStyle="#281509";mctx.fillRect(S9X,YT+1,barEnd-S9X,barH-2);}}' +
'const px1=Math.max(BX+1,Math.min(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
'const px2=Math.min(BX+BW-1,Math.max(dbToX(smMin,BX,BW),dbToX(smPeak,BX,BW)));' +
'if(px2>px1){mctx.save();mctx.globalCompositeOperation="lighter";mctx.fillStyle="rgba(64,64,64,1)";mctx.fillRect(px1,YT+1,px2-px1,barH-2);mctx.restore();}' +
'const pX=dbToX(smPeak,BX,BW);' +
'if(pX>BX&&pX<=BX+BW){mctx.strokeStyle="#FFF8F8";mctx.lineWidth=2;mctx.beginPath();mctx.moveTo(pX,YT-barH);mctx.lineTo(pX,YB+barH);mctx.stroke();}' +
'mctx.lineWidth=1;mctx.strokeStyle="#283030";mctx.strokeRect(lblZoneL,YT,BX+BW-lblZoneL,barH);' +
'mctx.strokeStyle="#404040";mctx.beginPath();mctx.moveTo(BX,YT+1);mctx.lineTo(BX,YB-1);mctx.stroke();' +
'mctx.font="8px Courier New";' +
'for(let i=0;i<DBM_MARKS.length;i++){const x=dbToX(DBM_MARKS[i],BX,BW);' +
'mctx.strokeStyle="#DCE0E0";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_LONG);mctx.lineTo(x,YT-1);mctx.stroke();' +
'const t=DBM_LABELS[i],tw=mctx.measureText(t).width;' +
'mctx.fillStyle="#DCDCD0";mctx.fillText(t,Math.round(x-tw/2),YT-TICK_LONG-3);}' +
'for(let d=-125;d<-13;d+=5){const x=dbToX(d,BX,BW);' +
'mctx.strokeStyle="#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YT-TICK_MED);mctx.lineTo(x,YT-1);mctx.stroke();}' +
'for(let i=0;i<S_MARKS_DBM.length;i++){const x=dbToX(S_MARKS_DBM[i],BX,BW),ov=S_MARKS_DBM[i]>=-73;' +
'mctx.strokeStyle=ov?"#90A070":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,YB+1);mctx.lineTo(x,YB+TICK_LONG);mctx.stroke();' +
'const t=S_MARKS_LBL[i],tw=mctx.measureText(t).width;' +
'mctx.fillStyle=ov?"#90D090":"#E0E0E0";mctx.fillText(t,Math.round(x-tw/2),YB+TICK_LONG+11);}' +
'mctx.font="bold 12px Courier New";const th12=12;mctx.fillStyle="#DCD896";mctx.fillText(lblDbm,lblZoneL+4,YT-th12-2);' +
'mctx.font="8px Courier New";mctx.fillStyle="#90A070";mctx.fillText("dBm",lblZoneL+4+twDbm+2,YT-10);' +
'mctx.fillStyle="#F0F0F0";mctx.font="bold 12px Courier New";mctx.fillText(sLbl,lblZoneL+4,YB+14);}' +
'function paintTXMeter(){' +
'const w=smCv.clientWidth||340,h=smCv.clientHeight||64;' +
'const fwdW=cur.fwd_w||0,swr=Math.max(1,cur.swr||1),maxW=cur.pa_max_power||100;' +
'const SWR_HIGH=2.5,SWR_MAX=10.0,TICK_LONG=5;' +
'const swrHigh=swr>SWR_HIGH;' +
'mctx.font="bold 12px Courier New";' +
'const lblPwr=fwdW.toFixed(0)+"W",lblSWR="SWR:"+swr.toFixed(1);' +
'const maxTWPwr=mctx.measureText(maxW.toFixed(0)+"W").width;' +
'const maxTWSWR=mctx.measureText("SWR:10.0").width;' +
'mctx.font="8px Courier New";' +
'const labelNeed=Math.max(maxTWPwr,maxTWSWR)+8;' +
'const leftInfo=Math.max(62,labelNeed+4),BX=leftInfo,BW=Math.max(10,w-leftInfo-8),lblZoneL=BX-labelNeed;' +
'const YT=Math.round(h*44/100),YB=Math.round(h*58/100);' +
'const YM=Math.round((YT+YB)/2);' +
'const PY1=YT,PY2=YM-1,SY1=YM+1,SY2=YB;' +
'const swarnX=BX+Math.round(Math.max(0,Math.min(1,(SWR_HIGH-1)/(SWR_MAX-1)))*BW);' +
'function pwrX(p){return BX+Math.round(Math.max(0,Math.min(1,p))*BW);}' +
'function swrX(s){return BX+Math.round(Math.max(0,Math.min(1,(s-1)/(SWR_MAX-1)))*BW);}' +
'mctx.clearRect(0,0,w,h);' +
// фоновые зоны
'mctx.fillStyle="#001F09";mctx.fillRect(BX,PY1,BW,PY2-PY1);' +
'mctx.fillStyle="#001F09";mctx.fillRect(BX,SY1,swarnX-BX,SY2-SY1);' +
'mctx.fillStyle="#281509";mctx.fillRect(swarnX,SY1,BX+BW-swarnX,SY2-SY1);' +
// бар мощности
'const pEnd=pwrX(maxW>0?fwdW/maxW:0);' +
'if(pEnd>BX){mctx.fillStyle="#44CC00";mctx.fillRect(BX,PY1+1,pEnd-BX,PY2-PY1-2);}' +
// бар КСВ
'const sEnd=swrX(swr);' +
'if(sEnd>BX){' +
'if(!swrHigh){mctx.fillStyle="#FFAA00";mctx.fillRect(BX,SY1+1,sEnd-BX,SY2-SY1-2);}' +
'else{const safeEnd=Math.min(swarnX,sEnd);' +
'if(safeEnd>BX){mctx.fillStyle="#FFAA00";mctx.fillRect(BX,SY1+1,safeEnd-BX,SY2-SY1-2);}' +
'if(sEnd>swarnX){mctx.fillStyle="#CC0000";mctx.fillRect(swarnX,SY1+1,sEnd-swarnX,SY2-SY1-2);}}}' +
// делитель 2.5
'mctx.strokeStyle="#404040";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(swarnX,SY1);mctx.lineTo(swarnX,SY2);mctx.stroke();' +
// рамки
'mctx.lineWidth=1;mctx.strokeStyle="#283030";mctx.strokeRect(lblZoneL,PY1,BX+BW-lblZoneL,PY2-PY1);' +
'mctx.strokeStyle=swrHigh?"#CC0000":"#283030";mctx.strokeRect(lblZoneL,SY1,BX+BW-lblZoneL,SY2-SY1);' +
// разделители зона надписей / шкала
'mctx.strokeStyle="#404040";mctx.lineWidth=1;' +
'mctx.beginPath();mctx.moveTo(BX,PY1+1);mctx.lineTo(BX,PY2-1);mctx.stroke();' +
'mctx.beginPath();mctx.moveTo(BX,SY1+1);mctx.lineTo(BX,SY2-1);mctx.stroke();' +
// тики мощности выше power-бара
'mctx.font="8px Courier New";const TICK_MED=3;' +
// промежуточные тики каждые 5%, пропуская кратные 25%
'for(let p=5;p<100;p+=5){if(p%25!==0){const x=pwrX(p/100);' +
'mctx.strokeStyle="#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,PY1-TICK_MED);mctx.lineTo(x,PY1-1);mctx.stroke();}}' +
// главные тики с подписями
'const pwrMarks=[0,0.25,0.5,0.75,1.0];' +
'for(let i=0;i<pwrMarks.length;i++){const x=pwrX(pwrMarks[i]);' +
'mctx.strokeStyle="#DCE0E0";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,PY1-TICK_LONG);mctx.lineTo(x,PY1-1);mctx.stroke();' +
'const t=(pwrMarks[i]*maxW).toFixed(0),tw=mctx.measureText(t).width;' +
'mctx.fillStyle="#DCDCD0";mctx.fillText(t,Math.round(x-tw/2),PY1-TICK_LONG-3);}' +
// промежуточные тики КСВ на целых 2,3,4,6,7,8,9
'for(let s=2;s<=9;s++){if(s===5)continue;const x=swrX(s),isW=s>=3;' +
'mctx.strokeStyle=isW?"#90A070":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,SY2+1);mctx.lineTo(x,SY2+TICK_MED);mctx.stroke();}' +
// тики КСВ ниже SWR-бара
'const swrMarks=[1,2.5,5,10],swrLbls=["1","2.5","5","10"];' +
'for(let i=0;i<swrMarks.length;i++){const x=swrX(swrMarks[i]),isW=swrMarks[i]>=SWR_HIGH;' +
'mctx.strokeStyle=isW?"#90A070":"#708070";mctx.lineWidth=1;mctx.beginPath();mctx.moveTo(x,SY2+1);mctx.lineTo(x,SY2+TICK_LONG);mctx.stroke();' +
'const t=swrLbls[i],tw=mctx.measureText(t).width;' +
'mctx.fillStyle=isW?(swrHigh?"#CC0000":"#90D090"):"#E0E0E0";' +
'mctx.fillText(t,Math.round(x-tw/2),SY2+TICK_LONG+11);}' +
// надписи в левой зоне
'mctx.font="bold 12px Courier New";' +
'mctx.fillStyle="#DCD896";mctx.fillText(lblPwr,lblZoneL+4,PY1-12-2);' +
'mctx.fillStyle=swrHigh?"#CC0000":"#F0F0F0";mctx.fillText(lblSWR,lblZoneL+4,SY2+14);' +
// "SWR High" рядом с мощностью
'if(swrHigh){const twP=mctx.measureText(lblPwr).width;' +
'mctx.font="bold 8px Courier New";mctx.fillStyle="#CC0000";' +
'mctx.fillText("SWR High",lblZoneL+4+twP+2,PY1-8);}}' +
// pendingCmds: команды в пути — не перезаписываем эти поля из server state
'var pendingCmds={};' +
'function pend(field){pendingCmds[field]=Date.now()+600;}' +
'function isPending(field){return !!(pendingCmds[field]&&pendingCmds[field]>Date.now());}' +
// Маппинг: server JSON field → pend key
'var PEND_MAP={vfo_a_hz:"freq",vfo_b_hz:"freq",nr:"nr",nr_mode:"nr",nb:"nb",nb_mode:"nb",snb:"snb",anf:"anf",running:"run",mode:"mode",agc_mode:"agc",transmitting:"mox",drive:"drive",attn_idx:"attn",tuning:"tun",filter_bw:"filter",mute:"mute",beacon_on:"beacon",rx_mute_on:"rxmute",rx_gain_mode:"rxgain",rx_gain_db:"rxgain"};' +
'function applyState(msg){' +
'Object.keys(msg).forEach(function(k){var pk=PEND_MAP[k];if(!pk||!isPending(pk))cur[k]=msg[k];});' +
// Перестраиваем bandSel если изменился список XVTR или текущий индекс
'if(msg.xvtr_bands!=null||msg.xvtr_current!=null){' +
'const newBands=msg.xvtr_bands||XVTR_BANDS;' +
'const newCur=(msg.xvtr_current!=null)?msg.xvtr_current:XVTR_CUR;' +
'const bandsChanged=JSON.stringify(newBands)!==JSON.stringify(XVTR_BANDS);' +
'const curChanged=newCur!==XVTR_CUR;' +
'XVTR_BANDS=newBands;XVTR_CUR=newCur;' +
'if(bandsChanged)rebuildBandSel();' +
'else if(curChanged){const s=q("bandSel");if(s&&XVTR_CUR>=0)s.value="x"+XVTR_CUR;}' +
'}' +
// Пресеты sample-rate сменились (другой бэкенд) — перестраиваем span-кнопки.
'if(msg.rate_presets!=null&&Array.isArray(msg.rate_presets)&&msg.rate_presets.length){' +
'if(JSON.stringify(msg.rate_presets)!==JSON.stringify(SPANS)){SPANS=msg.rate_presets;buildSpanButtons();}}' +
// Band-план сменился (другой бэкенд: HF↔VHF/UHF) — перестраиваем band-selector.
'if(msg.bands!=null&&Array.isArray(msg.bands)&&msg.bands.length){' +
'const nn=msg.bands.map(function(b){return b.name;});' +
'if(JSON.stringify(nn)!==JSON.stringify(BAND_N)){BAND_N=nn;BAND_F=msg.bands.map(function(b){return b.freq;});rebuildBandSel();}}' +
'if(msg.freq_min!=null)freqMin=msg.freq_min;if(msg.freq_max!=null)freqMax=msg.freq_max;' +
// Обновляем localCenter/localSpan из сервера только когда пользователь не тянет
'if(!dragging&&Date.now()-wheelActiveTs>300){' +
// В CTUN без DUP: TX-спектр WDSP центрирован по vfo_a_hz, а не по DDC LO.
// В CTUN с DUP: показываем RX-спектр → center_hz остаётся.
'if(msg.center_hz!=null)localCenter=(cur.transmitting&&cur.ctun&&!cur.dup)?cur.vfo_a_hz:msg.center_hz;' +
'if(msg.span_hz!=null)localSpan=msg.span_hz||192000;' +
'}' +
'if(!isPending("freq")){vfoA=cur.vfo_a_hz||vfoA;}' +
'if(msg.vfo_b_hz!=null)vfoB=msg.vfo_b_hz;' +
'if(msg.freq_mhz_digits!=null){freqMhzDigits=Math.max(3,Math.min(5,msg.freq_mhz_digits|0));freqMaxHz=Math.pow(10,freqMhzDigits+6)-1;}' +
'if(msg.fmstep_idx!=null){fmStepIdx=Math.max(0,Math.min(3,msg.fmstep_idx|0));if(inFMStep)syncStepSel();}' +
'if(msg.active_vfo!=null&&!isPending("freq")){' +
'const sv=msg.active_vfo===0?"A":"B";' +
'if(sv!==activeVfo){activeVfo=sv;' +
'q("vBtnA").classList.toggle("active",activeVfo==="A");' +
'q("vBtnB").classList.toggle("active",activeVfo==="B");' +
'q("vDispA").classList.toggle("dim",activeVfo!=="A");' +
'q("vDispB").classList.toggle("dim",activeVfo!=="B");}}' +
'renderVfos();' +
'const as=q("agcSel");if(as&&cur.agc_mode!=null&&!isPending("agc"))as.value=cur.agc_mode;' +
'const vs=q("volSl");if(vs&&!vs.matches(":active")){vs.value=cur.volume||70;var vv=q("volVal");if(vv)vv.textContent=cur.volume||70;}' +
'const rs=q("rfSl");if(rs&&!rs.matches(":active")){rs.value=cur.agc_top||90;var rv=q("rfVal");if(rv)rv.textContent=(cur.agc_top||90)+"dB";}' +
'if(!isPending("nr"))updNrBtn();if(!isPending("nb"))updNbBtn();' +
'if(!isPending("snb"))updSnbBtn();if(!isPending("anf"))updAnfBtn();' +
'if(!isPending("beacon"))updBcnBtn();' +
'if(!isPending("rxmute"))updRxMute();updRfAgc();' +
'if(!isPending("run"))updRunBtn(!!cur.running);' +
'if(!isPending("mox"))updMoxBtn(!!cur.transmitting);' +
'if(!isPending("tun"))updToneBtn(!!cur.tuning);' +
'if(!isPending("mute"))updMuteIc();' +
'if(!isPending("drive")){const ts=q("txSl");if(ts&&!ts.matches(":active")){ts.value=cur.drive||0;var tv=q("txVal");if(tv)tv.textContent=cur.drive||0;}}' +
'if(!isPending("mode")){syncModGrp(cur.mode||0);syncStepSel();}' +
'if(!isPending("attn")){const at=q("attSel");if(at)at.value=cur.attn_idx||0;}' +
'updBand(activeVfo==="A"?(cur.vfo_a_hz||0):(vfoB||0));updSpan(cur.span_hz||192000);' +
'paintSM(Number(cur.smeter_dbm||-130));' +
'updStatusBar();' +
'if(devList)renderDevList();' +
'const nowTx=!!cur.transmitting;if(nowTx!==prevTx){prevTx=nowTx;if(nowTx)startTXMic();else stopTXMic();}}' +
'function pal(db){const v=Math.round(cl((db-wfLo)/Math.max(1,wfHi-wfLo),0,1)*255);const c=PAL[v]|0;return[(c>>16)&255,(c>>8)&255,c&255];}' +
'function fltLH(){const bw=cur.filter_bw||2700;const m=cur.mode|0;if(m===M_LSB)return[-bw,-100];if(m===M_USB)return[100,bw];return[-bw/2,bw/2];}' +
'function xToHz(x,w){return localCenter+((x/Math.max(1,w))-.5)*localSpan;}' +
'function hzToX(f,w){return((f-localCenter)/localSpan+.5)*w;}' +
'function drawMk(ctx,w,h){if(!markerOn)return;ctx.save();ctx.strokeStyle="rgba(255,80,80,.85)";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(markerX,0);ctx.lineTo(markerX,h);ctx.stroke();' +
'const lbl=((xToHz(markerX,w))/1e6).toFixed(4)+" M";ctx.fillStyle="rgba(35,5,5,.82)";ctx.fillRect(cl(markerX+5,0,w-88),h-16,84,13);ctx.fillStyle="#f88";ctx.font="10px monospace";ctx.fillText(lbl,cl(markerX+8,2,w-86),h-6);ctx.restore();}' +
// Маркеры QO-100 маяка: опорная (зелёная пунктир) + ФИЛЬТР по ширине маяка из
// ДВУХ ПОЛОВИНОК (две несущие/лепестка Manchester ±400Гц, нуль по центру).
'var BCN_HALF=450;' + // полуширина фильтра маяка, Гц (как десктоп dec-маркер)
'function vbar(ctx,x,h,col,w){if(x<0||x>w)return;ctx.strokeStyle=col;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(x+.5,0);ctx.lineTo(x+.5,h);ctx.stroke();}' +
'function drawBcn(ctx,w,h){if(!cur.beacon_on)return;ctx.save();' +
// Опорная — где маяк ДОЛЖЕН быть.
'if(cur.beacon_ref_hz>0){ctx.setLineDash([4,3]);vbar(ctx,hzToX(cur.beacon_ref_hz,w),h,"rgba(80,255,120,.85)",w);ctx.setLineDash([]);}' +
// Фильтр маяка вокруг измеренной частоты: левая+правая половины (лепестки) с
// разделом по центру (нуль несущей) и гранями полосы.
'var t=cur.beacon_track_hz;' +
'if(t>0){var xL=hzToX(t-BCN_HALF,w),xC=hzToX(t,w),xR=hzToX(t+BCN_HALF,w);' +
'ctx.fillStyle="rgba(255,170,60,.14)";' +
'ctx.fillRect(xL,0,Math.max(0,xC-xL-1),h);' + // левая половина
'ctx.fillRect(xC+1,0,Math.max(0,xR-xC-1),h);' + // правая половина
'vbar(ctx,xL,h,"rgba(255,170,60,.75)",w);vbar(ctx,xR,h,"rgba(255,170,60,.75)",w);' + // грани
'vbar(ctx,xC,h,"rgba(255,170,60,.4)",w);}' + // центр (нуль/раздел)
'ctx.restore();}' +
'function drawRuler(){const w=rulerCv.clientWidth,h=rulerCv.clientHeight;rctx.fillStyle="#0c1210";rctx.fillRect(0,0,w,h);' +
'const st=localCenter-localSpan/2;' +
'const isFM=(cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW),fmStep=FM_STEP_HZ[fmStepIdx];' +
'if(isFM&&fmStep>0){' +
'const pxPerStep=w*fmStep/localSpan;' +
'const labelMult=Math.max(1,Math.ceil((rctx.measureText("000.000").width+6)/pxPerStep));' +
'const firstN=Math.ceil(st/fmStep);' +
'for(let n=firstN;n*fmStep<=localCenter+localSpan/2+0.5;n++){' +
'const f=n*fmStep,x=(f-st)/localSpan*w;if(x<0||x>=w)continue;' +
'rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);' +
'if((n%labelMult)===0){rctx.lineTo(x+.5,7);rctx.stroke();rctx.fillStyle="#7a9a80";rctx.font="10px monospace";const t=(f/1e6).toFixed(3);rctx.fillText(t,x-rctx.measureText(t).width/2,21);}' +
'else{rctx.lineTo(x+.5,4);rctx.stroke();}}}' +
'else{for(let i=0;i<=8;i++){const x=i*w/8,f=st+i*localSpan/8;rctx.strokeStyle="#2a3a2e";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(x+.5,0);rctx.lineTo(x+.5,7);rctx.stroke();rctx.fillStyle="#7a9a80";rctx.font="10px monospace";const t=(f/1e6).toFixed(3);rctx.fillText(t,x-14,21);}}' +
'const vxr=hzToX(activeVfoHz(),w);rctx.strokeStyle="#60d070";rctx.lineWidth=1;rctx.beginPath();rctx.moveTo(vxr+.5,0);rctx.lineTo(vxr+.5,h);rctx.stroke();}' +
'function drawSpec(bins){const w=specCv.clientWidth,h=specCv.clientHeight;sctx.fillStyle="#050a05";sctx.fillRect(0,0,w,h);' +
'for(let i=1;i<5;i++){const y=i*h/5;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(0,y);sctx.lineTo(w,y);sctx.stroke();}' +
'if((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&FM_STEP_HZ[fmStepIdx]>0){' +
'const fmSt=FM_STEP_HZ[fmStepIdx],st2=localCenter-localSpan/2;' +
'const pxSt=w*fmSt/localSpan,gm=Math.max(1,Math.ceil(4/pxSt));' +
'const fn=Math.ceil(st2/fmSt);' +
'for(let n=fn;n*fmSt<=localCenter+localSpan/2+0.5;n++){' +
'if((n%gm)!==0)continue;const x=(n*fmSt-st2)/localSpan*w;' +
'if(x<0||x>=w)continue;sctx.strokeStyle="#121e12";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(x+.5,0);sctx.lineTo(x+.5,h);sctx.stroke();}}' +
'if(!bins?.length){drawMk(sctx,w,h);return;}' +
'if(specSm.length!==bins.length)specSm=new Array(bins.length).fill(-130);' +
'const avf=activeVfoHz();const[fl,fh]=fltLH(),x1=hzToX(avf+fl,w),x2=hzToX(avf+fh,w);' +
'sctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.18)":"rgba(80,150,255,.12)";sctx.fillRect(Math.min(x1,x2),0,Math.abs(x2-x1),h);' +
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));specSm[i]=specSm[i]*.70+bins[i]*.30;const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'const g=sctx.createLinearGradient(0,0,0,h);g.addColorStop(0,"rgba(60,230,60,.2)");g.addColorStop(1,"rgba(60,230,60,.01)");' +
'sctx.lineTo(w,h);sctx.lineTo(0,h);sctx.closePath();sctx.fillStyle=g;sctx.fill();' +
'sctx.beginPath();for(let x=0;x<w;x++){const i=Math.floor(x*(bins.length-1)/Math.max(1,w-1));const y=cl(((-specSm[i]-20)/120)*h,0,h-1);x===0?sctx.moveTo(0,y):sctx.lineTo(x,y);}' +
'sctx.strokeStyle="#3ddd3d";sctx.lineWidth=1.2;sctx.stroke();' +
'const vx=hzToX(avf,w);sctx.strokeStyle="#60ff80";sctx.lineWidth=1;sctx.beginPath();sctx.moveTo(vx,0);sctx.lineTo(vx,h);sctx.stroke();drawBcn(sctx,w,h);drawMk(sctx,w,h);}' +
'function drawWf(bins){const w=wfCv.clientWidth,h=wfCv.clientHeight;' +
'if(!bins?.length){drawMk(wctx,w,h);return;}' +
'if(wfBuf.width!==w||wfBuf.height!==h){wfBuf.width=w;wfBuf.height=h;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,w,h);}' +
'if(wfAvg.length!==bins.length){wfAvg=new Array(bins.length).fill(-120);wfHi=-50;wfLo=-120;}' +
'const useAgc=cur.wf_agc!==false;const useNf=cur.wf_nf!==false;' +
'for(let i=0;i<bins.length;i++){wfAvg[i]=wfAvg[i]*.75+bins[i]*.25;}' +
'let peak=-200,nfSum=0,nfCount=0;' +
'for(let i=0;i<bins.length;i++){if(wfAvg[i]>peak)peak=wfAvg[i];}' +
'for(let i=0;i<bins.length;i++){if(wfAvg[i]<wfLo+15){nfSum+=wfAvg[i];nfCount++;}}' +
'if(peak>wfHi)wfHi=peak+5;else wfHi=wfHi+0.02*((peak+5)-wfHi);' +
'if(nfCount>10)wfLo=wfLo+0.005*(((nfSum/nfCount)-5)-wfLo);' +
'let hHi=wfHi,hLo=wfLo;if(hHi<hLo+15)hHi=hLo+15;' +
'if(hHi>0)hHi=0;if(hLo<-160)hLo=-160;wfHi=hHi;wfLo=hLo;' +
'wfBCtx.drawImage(wfBuf,0,0,w,h-1,0,1,w,h-1);const row=wfBCtx.createImageData(w,1);' +
'for(let x=0;x<w;x++){const i=Math.floor(x*(wfAvg.length-1)/Math.max(1,w-1));const[r,g,b]=pal(wfAvg[i]);const o=x*4;row.data[o]=r;row.data[o+1]=g;row.data[o+2]=b;row.data[o+3]=255;}' +
'wfBCtx.putImageData(row,0,0);wctx.drawImage(wfBuf,0,0,w,h);' +
'const avf2=activeVfoHz();const[fl2,fh2]=fltLH(),x1w=hzToX(avf2+fl2,w),x2w=hzToX(avf2+fh2,w);' +
'wctx.fillStyle=(cur.transmitting||cur.tuning)?"rgba(255,60,60,.15)":"rgba(220,230,230,.10)";wctx.fillRect(Math.min(x1w,x2w),0,Math.abs(x2w-x1w),h);' +
'const vxw=hzToX(avf2,w);wctx.strokeStyle="#60ff80";wctx.lineWidth=1;wctx.beginPath();wctx.moveTo(vxw,0);wctx.lineTo(vxw,h);wctx.stroke();drawBcn(wctx,w,h);drawMk(wctx,w,h);}' +
'function rcv(cv){const dpr=Math.max(1,devicePixelRatio||1),w=cv.clientWidth|0,h=cv.clientHeight|0;if(cv.width!==(w*dpr|0)||cv.height!==(h*dpr|0)){cv.width=w*dpr;cv.height=h*dpr;cv.getContext("2d").setTransform(dpr,0,0,dpr,0,0);}}' +
'function resize(){rcv(specCv);rcv(wfCv);rcv(rulerCv);const dpr=Math.max(1,devicePixelRatio||1),sw=smCv.clientWidth|0,sh=smCv.clientHeight|0;' +
'if(smCv.width!==(sw*dpr|0)||smCv.height!==(sh*dpr|0)){smCv.width=sw*dpr|0;smCv.height=sh*dpr|0;mctx.setTransform(dpr,0,0,dpr,0,0);}' +
'if(wfBuf.width!==wfCv.clientWidth||wfBuf.height!==wfCv.clientHeight){wfBuf.width=wfCv.clientWidth;wfBuf.height=wfCv.clientHeight;wfBCtx.fillStyle="#000";wfBCtx.fillRect(0,0,wfBuf.width,wfBuf.height);}}' +
'function syncStepSel(){const isFM=(cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW);const sel=q("stepSel");if(!sel)return;' +
'if(isFM&&!inFMStep){lastHFStep=+sel.value||100;sel.innerHTML="";' +
'FM_STEP_LBL.forEach((l,i)=>{const o=document.createElement("option");o.value=FM_STEP_HZ[i];o.textContent=l;sel.appendChild(o);});' +
'sel.value=FM_STEP_HZ[fmStepIdx];' +
'sel.onchange=function(){if(!inFMStep)return;const hz=+sel.value;const idx=FM_STEP_HZ.indexOf(hz);if(idx>=0){fmStepIdx=idx;ws2({cmd:"fm_step",idx:idx});};};' +
'inFMStep=true;}' +
'else if(!isFM&&inFMStep){sel.onchange=null;sel.innerHTML=["10 Hz","100 Hz","1 kHz","10 kHz"].map((l,i)=>`<option value="${[10,100,1000,10000][i]}">${l}</option>`).join("");' +
'sel.value=lastHFStep;inFMStep=false;}' +
'else if(isFM){sel.value=FM_STEP_HZ[fmStepIdx];}}' +
'function tuneStep(e){const s=+q("stepSel").value||100;if(e.ctrlKey&&e.shiftKey)return 1000000;if(e.ctrlKey)return 1000;if(e.shiftKey)return 10;return s;}' +
'function activeVfoHz(){return activeVfo==="A"?vfoA:vfoB;}' +
'function setActiveVfoHz(f){if(activeVfo==="A")vfoA=f;else vfoB=f;}' +
'function sendActiveFreq(f){if(activeVfo==="A")ws2({cmd:"freq",hz:f});else ws2({cmd:"freq",hz:f});}' +
// Границы перестройки берём из бэкенда (freqMin/freqMax из state), а не
// хардкодим HF — иначе на Pluto (VHF/UHF) drag по спектру клампило в 60 МГц.
'function vfoMin(){return XVTR_CUR>=0?0:Math.max(30000,freqMin);}' +
'function vfoMax(){return XVTR_CUR>=0?999999999999:freqMax;}' +
'function doWheel(e){e.preventDefault();e.stopPropagation();audioKick();const dir=e.deltaY<0?1:-1,st=tuneStep(e),base=Math.floor(activeVfoHz()/st)*st;' +
'const nf=cl(dir>0?base+st:(Math.round(activeVfoHz())===base?base-st:base),vfoMin(),vfoMax());' +
'setActiveVfoHz(nf);wheelActiveTs=Date.now();' +
'if(activeVfo==="A"){cur.vfo_a_hz=nf;}else{cur.vfo_b_hz=nf;}' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});}' +
'function bindCv(cv){let wT=0;const wh=e=>{const n=performance.now();if(n-wT<20)return;wT=n;wTarget=cv.id;doWheel(e);};' +
'cv.addEventListener("contextmenu",e=>e.preventDefault());' +
'cv.addEventListener("mouseenter",()=>{wTarget=cv.id;});' +
'cv.addEventListener("wheel",wh,{passive:false,capture:true});' +
'cv.addEventListener("mousedown",e=>{' +
'audioKick();wTarget=cv.id;' +
'if(e.button===2){markerOn=Math.abs(markerX-e.offsetX)>=8||!markerOn;markerX=e.offsetX;return;}' +
'if(e.button!==0)return;' +
// Наведение маяка — на mousedown с немедленным return (как десктоп FormMouseDown):
// НЕ стартуем drag, чтобы не уехал set_center/VFO и не порвался IQ под локом.
// Только когда «вооружено» (1-й клик после BCN) или с Shift (перенаведение).
'if(cur.beacon_on&&(beaconArm||e.shiftKey)){beaconArm=false;' +
'ws2({cmd:"beacon_seed",frac:cl(e.offsetX/Math.max(1,cv.clientWidth),0,1)});return;}' +
'dragging=true;dragX0=e.offsetX;' +
'dragCen0=localCenter;' +
'dragVfo0=activeVfoHz();' +
'dragMs=0;cv.style.cursor="ew-resize";' +
'});' +
'cv.addEventListener("mousemove",e=>{' +
'wTarget=cv.id;hoverX=e.offsetX;' +
'if(markerOn&&!dragging)markerX=e.offsetX;' +
'if(!dragging)return;' +
'const n=Date.now();if(n-dragMs<16)return;dragMs=n;' +
'const dHz=(e.offsetX-dragX0)/Math.max(1,cv.clientWidth)*localSpan;' +
'if(cur.ctun){' +
'localCenter=Math.round(dragCen0-dHz);' +
'ws2({cmd:"set_center",hz:localCenter});' +
'}else{' +
'const nf=cl(Math.round(dragVfo0-dHz),vfoMin(),vfoMax());' +
'setActiveVfoHz(nf);localCenter=nf;' +
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
'}' +
'});' +
'cv.addEventListener("mouseup",e=>{' +
'cv.style.cursor="";' +
'if(e.button===2)return;' +
'if(dragging&&Math.abs(e.offsetX-dragX0)<4){' +
// клик — не трогаем localCenter: фильтр встаёт точно туда куда кликнули.
// Спектр остаётся на месте, сервер пришлёт center_hz через ~200ms и всё перецентрируется.
'const snap=((cur.mode===M_FM||cur.mode===M_DMR||cur.mode===M_FMRAW)&&inFMStep)?FM_STEP_HZ[fmStepIdx]:100;' +
'const nf=cl(Math.round(xToHz(e.offsetX,cv.clientWidth)/snap)*snap,vfoMin(),vfoMax());' +
'setActiveVfoHz(nf);' +
'if(activeVfo==="A")cur.vfo_a_hz=nf;else cur.vfo_b_hz=nf;' +
'renderVfos();pend("freq");ws2({cmd:"freq",hz:nf});' +
'}' +
'dragging=false;' +
'});' +
'cv.addEventListener("mouseleave",()=>{cv.style.cursor="";if(!markerOn)hoverX=-1;wTarget="";dragging=false;});}' +
'bindCv(specCv);bindCv(wfCv);' +
// Touch→mouse мост для канвасов: drag-to-pan и tap-to-tune на мобильных
'function addTouch(cv){' +
'function mkMs(type,t){return new MouseEvent(type,{bubbles:true,cancelable:true,clientX:t.clientX,clientY:t.clientY});}' +
'cv.addEventListener("touchstart",e=>{e.preventDefault();audioKick();cv.dispatchEvent(mkMs("mousedown",e.touches[0]));},{passive:false});' +
'cv.addEventListener("touchmove",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mousemove",e.touches[0]));},{passive:false});' +
'cv.addEventListener("touchend",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mouseup",e.changedTouches[0]));},{passive:false});}' +
'addTouch(specCv);addTouch(wfCv);' +
'window.addEventListener("wheel",e=>{if(e.target?.closest?.(".vfo-ro"))return;if(!wTarget){if(e.target===specCv)wTarget="spec";else if(e.target===wfCv)wTarget="wf";}if(wTarget)doWheel(e);},{passive:false,capture:true});' +
// Opus: async decodeFrame, очередь, оверлей для user gesture
'let ws=null,opDec=null,opRdy=false,opQueue=[];' +
'let txMicStream=null,txMicACtx=null,txMicProc=null,txMicEnc=null,txMicGain=null,prevTx=false,txMicStarting=false;' +
'async function initOpus(){if(opRdy)return;' +
'try{const m=await import("https://cdn.jsdelivr.net/npm/opus-decoder@0.7.7/+esm");' +
'opDec=new m.OpusDecoder({channels:1,sampleRate:48000});' +
'await opDec.ready;opRdy=true;' +
'const q2=opQueue;opQueue=[];for(const b of q2)await playOpus(b);' +
'latEl.textContent="Opus ready";}catch(e){console.error("Opus init:",e);latEl.textContent="Opus FAILED: "+e.message;}}' +
'async function playOpus(b){' +
'if(!b||!b.length)return;' +
'if(!opRdy){opQueue.push(b);return;}' +
'if(!audioCtx)return;' +
'try{const r=await opDec.decodeFrame(b);' +
'if(!r||!r.channelData||!r.samplesDecoded)return;' +
'const pcm=r.channelData[0];' +
'const buf=audioCtx.createBuffer(1,r.samplesDecoded,48000);' +
'buf.copyToChannel(pcm.subarray(0,r.samplesDecoded),0);' +
'const src=audioCtx.createBufferSource();src.buffer=buf;src.connect(audioCtx.destination);' +
'const now=audioCtx.currentTime;' +
'if(audioT<now-.05||audioT>now+.5)audioT=now+.02;' +
'src.start(audioT);audioT+=r.samplesDecoded/48000;' +
'latEl.textContent="lat: "+Math.max(0,(audioT-now)*1000).toFixed(0)+"ms | Opus";' +
'}catch(e){console.error("playOpus:",e);}}' +
// TX mic pipeline — persistent between MOX presses to avoid macOS/Android
// hardware acquire/release delay (rapid stop+start fails silently on these OSes).
// cleanupTXMic: full teardown, called only on WS disconnect.
// stopTXMic: called on MOX off — keeps pipeline warm, just clears the guard flag.
// startTXMic: if pipeline healthy — nothing to do; if broken stream — cleanup+restart.
'function cleanupTXMic(){' +
'txMicStarting=false;' +
'if(txMicProc){txMicProc.disconnect();txMicProc=null;}' +
'if(txMicGain){try{txMicGain.disconnect();}catch(ex){}txMicGain=null;}' +
'if(txMicEnc){try{txMicEnc.close();}catch(ex){}txMicEnc=null;}' +
'if(txMicACtx){txMicACtx.close();txMicACtx=null;}' +
'if(txMicStream){txMicStream.getTracks().forEach(function(t){t.stop();});txMicStream=null;}}' +
// stopTXMic: no-op. Поток заглушается флагом cur.transmitting в worklet/output callback.
// Раньше тут сбрасывался txMicStarting — это создавало race: при быстром RX→TX во время
// await getUserMedia запускался параллельный startTXMic с дублирующими ресурсами.
'function stopTXMic(){}' +
'async function startTXMic(){' +
// Pipeline already running and healthy — just re-resume ctx (Android может уронить его в suspended между TX)
'if(txMicStream&&txMicEnc&&txMicEnc.state==="configured"){if(txMicACtx&&txMicACtx.state!=="running"){try{await txMicACtx.resume();}catch(e){}}return;}' +
// Broken state (stream alive but encoder dead) — full cleanup before restart
'if(txMicStream)cleanupTXMic();' +
'if(txMicStarting)return;' +
'txMicStarting=true;' +
'try{' +
// autoGainControl:false ОБЯЗАТЕЛЬНО — иначе браузер нормализует громкость
// и ручной MIC GainNode (txMicGain) не даёт никакого эффекта.
'txMicStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:48000,echoCancellation:true,noiseSuppression:true,autoGainControl:false}});' +
'if(!txMicStream)return;' +
// Контекст обычно уже создан в click-обработчике (user-gesture); создаём fallback только если нет
'if(!txMicACtx)txMicACtx=new AudioContext({sampleRate:48000});' +
'if(txMicACtx.state!=="running"){try{await txMicACtx.resume();}catch(e){}}' +
'const msrc=txMicACtx.createMediaStreamSource(txMicStream);' +
'txMicEnc=new AudioEncoder({' +
'output:(chunk)=>{' +
'if(!cur.transmitting||!ws||ws.readyState!==1)return;' +
'const pkt=new Uint8Array(1+chunk.byteLength);pkt[0]=0x4D;' +
'chunk.copyTo(pkt.subarray(1));ws.send(pkt.buffer);},' +
'error:(e)=>console.error("txenc:",e)});' +
'txMicEnc.configure({codec:"opus",sampleRate:48000,numberOfChannels:1,bitrate:32000});' +
// AudioWorklet processor: accumulates render quanta (128 samples) into 960-sample Opus frames
'const wcSrc=' +
'"class P extends AudioWorkletProcessor{" +' +
'"constructor(){super();this.a=new Float32Array(960);this.p=0;}" +' +
'"process(inp){const d=inp[0]&&inp[0][0];if(!d)return true;" +' +
'"let o=0;while(o<d.length){const t=Math.min(960-this.p,d.length-o);" +' +
'"this.a.set(d.subarray(o,o+t),this.p);this.p+=t;o+=t;" +' +
'"if(this.p===960){this.port.postMessage(this.a.slice());this.p=0;}}" +' +
'"return true;}}registerProcessor(\"txmic\",P);";' +
'const burl=URL.createObjectURL(new Blob([wcSrc],{type:"application/javascript"}));' +
'await txMicACtx.audioWorklet.addModule(burl);URL.revokeObjectURL(burl);' +
'let txTs=0;' +
'txMicProc=new AudioWorkletNode(txMicACtx,"txmic");' +
'txMicProc.port.onmessage=function(ev){' +
// Skip encoding when TX off — pipeline warm but not transmitting
'if(!cur.transmitting)return;' +
'if(!txMicEnc||txMicEnc.state!=="configured")return;' +
'const ad=new AudioData({format:"f32-planar",sampleRate:48000,numberOfFrames:960,numberOfChannels:1,timestamp:txTs,data:ev.data});' +
'txTs+=20000;txMicEnc.encode(ad);ad.close();};' +
// GainNode для регулировки усиления микрофона (значение из слайдера micSl).
'txMicGain=txMicACtx.createGain();txMicGain.gain.value=micGainVal/100;' +
'msrc.connect(txMicGain);txMicGain.connect(txMicProc);txMicProc.connect(txMicACtx.destination);' +
'}catch(e){console.error("startTXMic:",e);cleanupTXMic();}' +
'finally{txMicStarting=false;}}' +
'function connect(){const p=location.protocol==="https:"?"wss":"ws";ws=new WebSocket(p+"://"+location.host+"/ws");ws.binaryType="arraybuffer";' +
'ws.onopen=()=>{stEl.textContent="connected";initOpus();};' +
'ws.onclose=()=>{cleanupTXMic();stEl.textContent="disconnected...";setTimeout(connect,3000);};' +
'ws.onerror=()=>ws.close();' +
'ws.onmessage=e=>{if(typeof e.data==="string"){try{applyState(JSON.parse(e.data));}catch(x){}return;}' +
'const ab=e.data,t=(new DataView(ab)).getUint8(0);' +
'if(t===0x53){const n=(ab.byteLength-1)>>2;const tmp=new Uint8Array(ab,1);const al=new ArrayBuffer(n*4);new Uint8Array(al).set(tmp);const b=Array.from(new Float32Array(al));drawSpec(b);drawWf(b);drawRuler();}' +
'else if(t===0x41){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();playOpus(new Uint8Array(ab,1));}};}' +
// Оверлей для user gesture (AudioContext требует клика)
'function showAudioOverlay(){' +
'const ov=document.createElement("div");' +
'ov.style.cssText="position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer;";' +
'const inner=document.createElement("div");' +
'inner.textContent="\u25BA Click to start audio";' +
'inner.style.cssText="color:#7fff7f;font-size:22px;font-family:monospace;text-align:center;padding:20px;border:1px solid #3d7a3d;border-radius:6px;background:#0c1a0c;";' +
'ov.appendChild(inner);' +
'ov.addEventListener("click",function(){audioKick();ov.remove();},{once:true});' +
'document.body.appendChild(ov);}' +
'window.addEventListener("resize",resize);resize();renderVfos();connect();' +
'document.addEventListener("click",function(){if(audioCtx&&audioCtx.state==="suspended")audioCtx.resume();});' +
'setTimeout(showAudioOverlay,300);' +
'</script></body></html>';
end;
end.