Files
pi-multifx-pedal/src/web/ui-v2-dist/index.html
T
shawn 1b2747abb1 fix: audio params timeout - increase API timeout for JACK restart
The setAudioParams call restarts JACK which takes up to 10 seconds.
The default API timeout was 6s, causing the request to abort and
leaving JACK in a broken state.

- Changed setAudioParams to use API._fetch directly with timeout:15000
- Also calls loadAudioParams() after success to refresh dropdown values
2026-06-16 20:02:57 -04:00

1114 lines
66 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>Pedal v2</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{--bg:#0A0A0E;--surface:#16161E;--panel:#1E1E2A;--border:#2A2A3E;--text:#EAEAFF;--text-dim:#6A6A8A;--text-sec:#9A9AB0;--amber:#E8A030;--green:#50D090;--red:#E05050;--blue:#4090E0;--radius:10px;--font:'Space Grotesk','Inter',system-ui,-apple-system,sans-serif}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);overflow:hidden;touch-action:manipulation}
body{display:flex;flex-direction:column;max-height:100vh;user-select:none;-webkit-user-select:none}
/* Status Bar */
.status-bar{display:flex;align-items:center;gap:6px;padding:3px 8px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0;min-height:34px}
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.status-dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
.status-dot.off{background:var(--red)}
.status-channel{font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--amber);padding:2px 6px;border-radius:4px;background:rgba(232,160,48,.12);white-space:nowrap}
.status-preset{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:600;min-width:0}
.status-vol{font-size:10px;font-weight:700;color:var(--amber);white-space:nowrap;font-variant-numeric:tabular-nums}
.status-btn{width:40px;height:40px;border:none;background:transparent;color:var(--text-dim);font-size:16px;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:all .15s}
.status-btn:active{background:var(--border);color:var(--amber)}
/* Main content */
.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:4px 6px;gap:4px}
.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:3px 5px;gap:3px}
/* NAM Hero — fills available vertical space */
.nam-hero{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--surface);border-radius:12px;border:2px solid var(--border);position:relative;overflow:hidden;min-height:100px;cursor:pointer;transition:all .15s}
.nam-hero:active{transform:scale(.98)}
.nam-hero .nam-badge{position:absolute;top:4px;left:6px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:3px;background:rgba(232,160,48,.15);color:var(--amber)}
.nam-hero .nam-name{font-size:20px;font-weight:700;text-align:center;padding:0 12px;line-height:1.15;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;max-width:90%}
.nam-hero .nam-detail{font-size:10px;color:var(--text2);margin-top:2px;font-variant-numeric:tabular-nums}
.nam-hero .nam-led{position:absolute;top:4px;right:6px;width:10px;height:10px;border-radius:50%;box-shadow:0 0 6px}
.nam-hero .nam-led.on{background:var(--green);box-shadow:0 0 8px var(--green)}
.nam-hero .nam-led.off{background:var(--red);box-shadow:0 0 8px var(--red)}
.nam-hero.empty{border-style:dashed;border-color:var(--border);opacity:.5}
.nam-hero.empty .nam-name{font-size:14px;color:var(--text3)}
/* Pedal row — stompbox style FX blocks */
.pedal-row{display:flex;gap:5px;overflow-x:auto;overflow-y:hidden;flex-shrink:0;padding:2px 0;min-height:80px;scrollbar-width:none;-ms-overflow-style:none}
.pedal-row::-webkit-scrollbar{display:none}
.pedal{flex-shrink:0;width:80px;border-radius:8px;background:var(--card);border:2px solid var(--border);display:flex;flex-direction:column;align-items:center;cursor:pointer;transition:all .12s;position:relative;overflow:hidden;min-height:76px}
.pedal:active{transform:scale(.93)}
.pedal.on{border-color:var(--green)}
.pedal.off{opacity:.55;filter:saturate(.4)}
.pedal .pd-led{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}
.pedal .pd-led.on{background:var(--green);box-shadow:0 0 5px var(--green)}
.pedal .pd-led.off{background:var(--red)}
.pedal .pd-icon{font-size:13px;margin-top:3px;line-height:1}
.pedal .pd-label{font-size:8px;font-weight:600;text-align:center;padding:0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:100%;flex:1;display:flex;align-items:center;justify-content:center}
.pedal .pd-switch{width:28px;height:14px;border-radius:7px;margin-bottom:4px;flex-shrink:0;position:relative;transition:background .15s}
.pedal .pd-switch.on{background:var(--green);box-shadow:0 0 4px var(--green)}
.pedal .pd-switch.off{background:var(--border)}
.pedal .pd-switch::after{content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:var(--text);transition:transform .15s}
.pedal .pd-switch.on::after{transform:translateX(14px)}
.pedal .pd-add{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:20px;color:var(--text3);border:2px dashed var(--border);border-radius:8px}
.pedal .pd-add:active{color:var(--amber);border-color:var(--amber)}
/* Volume */
.vol-row{display:flex;align-items:center;gap:8px;flex-shrink:0;min-height:36px;padding:0 4px}
.vol-label{font-size:9px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em;width:44px;flex-shrink:0}
.vol-track{flex:1;height:36px;display:flex;align-items:center;cursor:pointer;touch-action:none;position:relative}
.vol-track-bg{width:100%;height:6px;border-radius:3px;background:var(--border);overflow:hidden;position:relative}
.vol-track-fill{height:100%;border-radius:3px;background:var(--amber);transition:width .05s linear}
.vol-track-thumb{position:absolute;top:50%;transform:translate(-50%,-50%);width:24px;height:24px;border-radius:50%;background:var(--amber);border:2px solid var(--bg);box-shadow:0 1px 6px rgba(0,0,0,.5);pointer-events:none}
.vol-pct{font-size:12px;font-weight:700;color:var(--amber);min-width:38px;text-align:right;font-variant-numeric:tabular-nums}
/* Bottom Tab Bar */
.tab-bar{display:flex;flex-shrink:0;background:var(--panel);border-top:1px solid var(--border);min-height:44px}
.tab-btn{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;border:none;background:transparent;color:var(--text-dim);cursor:pointer;padding:4px 2px;transition:all .1s;min-height:44px}
.tab-btn .tab-icon{font-size:14px;line-height:1}
.tab-btn .tab-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.tab-btn.active{color:var(--amber);background:rgba(232,160,48,.08)}
.tab-btn:active{opacity:.7}
/* Footswitch */
.footswitch-row{flex-shrink:0;padding:3px 6px}
.footswitch{width:100%;min-height:64px;border-radius:12px;border:none;font-family:var(--font);font-size:16px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:all .12s;display:flex;align-items:center;justify-content:center;gap:8px;box-shadow:0 3px 16px rgba(0,0,0,.4)}
.footswitch:active{transform:scale(.97)}
.footswitch.on{background:linear-gradient(180deg,#60D898,#30A068);color:#003020}
.footswitch.off{background:linear-gradient(180deg,#E86868,#B03030);color:#200000}
/* Overlays */
.overlay{position:fixed;inset:0;z-index:100;background:var(--bg);display:none;flex-direction:column;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
.overlay.open{display:flex}
.overlay-hdr{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0;min-height:38px}
.overlay-title{font-size:13px;font-weight:700}
.overlay-close{width:38px;height:38px;border:none;background:transparent;color:var(--text-dim);font-size:16px;border-radius:6px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.overlay-close:active{background:var(--border)}
.overlay-body{flex:1;overflow-y:auto;padding:6px 10px}
/* Preset Grid */
.preset-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:6px 0}
.preset-card{border-radius:var(--radius);border:1.5px solid var(--border);background:var(--surface);padding:10px 8px;cursor:pointer;transition:all .12s;min-height:52px;display:flex;flex-direction:column;gap:2px}
.preset-card:active{transform:scale(.97);border-color:var(--amber)}
.preset-card.active{border-color:var(--amber);background:rgba(232,160,48,.08)}
.preset-card .pc-num{font-size:9px;font-weight:700;color:var(--text-dim)}
.preset-card .pc-name{font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Tuner */
.tuner-view{position:fixed;inset:0;z-index:90;background:var(--bg);display:none;flex-direction:column;align-items:center;justify-content:center}
.tuner-view.open{display:flex}
.tuner-note{font-size:64px;font-weight:800;color:var(--text);line-height:1;margin-bottom:4px}
.tuner-cents{height:44px;display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:8px}
.tuner-cents-bar{width:100px;height:6px;border-radius:3px;background:var(--border);overflow:hidden;position:relative}
.tuner-cents-fill{height:100%;border-radius:3px;position:absolute}
.tuner-cents-fill.in-tune{background:var(--green);left:calc(50% - 3px);width:6px}
.tuner-cents-fill.sharp{background:var(--red)}
.tuner-cents-fill.flat{background:var(--blue)}
.tuner-freq{font-size:11px;color:var(--text-dim);margin-bottom:12px}
.tuner-exit{width:60%;max-width:260px;min-height:50px;border-radius:var(--radius);border:1.5px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:14px;font-weight:700;cursor:pointer}
.tuner-exit:active{background:var(--panel);border-color:var(--amber)}
/* Settings */
.setting-group{margin-bottom:12px}
.setting-group-title{font-size:10px;font-weight:700;color:var(--text-sec);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;padding:0 4px}
/* Settings section nav */
.settings-nav{display:flex;gap:6px;overflow-x:auto;padding:6px 10px 8px;flex-shrink:0;scrollbar-width:none;-ms-overflow-style:none;background:var(--bg);border-bottom:1px solid var(--border)}
.settings-nav::-webkit-scrollbar{display:none}
.sn-btn{flex-shrink:0;padding:8px 16px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text-dim);font-family:var(--font);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:all .12s;min-height:38px}
.sn-btn:active{background:var(--panel);transform:scale(.95)}
.sn-btn.active{border-color:var(--amber);color:var(--amber);background:rgba(232,160,48,.08)}
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);margin-bottom:3px;min-height:42px}
.setting-row .sr-label{font-size:11px;font-weight:600}
.setting-row .sr-value{font-size:10px;color:var(--text-sec)}
.setting-row .sr-action{min-width:52px;min-height:32px;border-radius:6px;border:1px solid var(--border);background:var(--panel);color:var(--text);font-family:var(--font);font-size:10px;font-weight:600;cursor:pointer;padding:0 8px;display:flex;align-items:center;justify-content:center}
.setting-row .sr-action:active{background:var(--border)}
.setting-row .sr-action.primary{background:var(--amber);color:#000;border-color:var(--amber)}
.setting-row .sr-action.danger{background:var(--red);color:#fff;border-color:var(--red)}
.wifi-list{display:flex;flex-direction:column;gap:3px}
.wifi-item{display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);cursor:pointer;min-height:40px}
.wifi-item:active{background:var(--panel)}
.wifi-item .wi-ssid{font-size:11px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wifi-connect-pw{display:flex;gap:4px;padding:6px 0}
.wifi-connect-pw input{flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px}
.wifi-connect-pw input:focus{border-color:var(--amber)}
.wifi-connect-pw button{min-width:52px;min-height:36px;border-radius:6px;border:1px solid var(--amber);background:var(--amber);color:#000;font-family:var(--font);font-size:11px;font-weight:700;cursor:pointer}
/* Loading overlay */
.loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:300;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px}
.loading-overlay .spinner{width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--amber);border-radius:50%;animation:spin .6s linear infinite}
.loading-overlay .load-msg{font-size:12px;font-weight:600;color:var(--text2)}
@keyframes spin{to{transform:rotate(360deg)}}
.snap-grid{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px}
.snap-slot{border-radius:6px;border:1px solid var(--border);background:var(--surface);padding:6px 3px;cursor:pointer;text-align:center;transition:all .12s;min-height:40px;display:flex;flex-direction:column;align-items:center;justify-content:center}
.snap-slot:active{transform:scale(.95)}
.snap-slot.used{border-color:var(--amber);background:rgba(232,160,48,.06)}
.snap-slot.current{border-color:var(--green);background:rgba(80,208,144,.1)}
.snap-slot .ss-num{font-size:9px;font-weight:700;color:var(--text-dim)}
.snap-slot .ss-name{font-size:9px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50px}
.spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--amber);border-radius:50%;animation:spin .6s linear infinite;display:inline-block;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:200;background:var(--panel);border:1px solid var(--amber);border-radius:8px;padding:6px 14px;font-size:11px;font-weight:600;color:var(--text);display:none;white-space:nowrap;box-shadow:0 4px 20px rgba(0,0,0,.5)}
.toast.show{display:block;animation:toastIn .2s ease}
@keyframes toastIn{from{opacity:0;transform:translateX(-50%) translateY(10px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
</style>
</head>
<body>
<!-- Status Bar -->
<div class="status-bar">
<div class="status-dot on" id="statusDot"></div>
<div class="status-channel" id="statusChannel" onclick="cycleInstrument()" style="cursor:pointer">Guitar</div>
<div class="status-preset" id="statusPreset"></div>
<div class="status-vol" id="statusVolPct">80%</div>
<button class="status-btn" id="btnChannel" onclick="cycleInstrument()">🎛</button>
<button class="status-btn" id="btnSettings"></button>
</div>
<!-- Main Content -->
<div class="main-content">
<div class="nam-hero empty" id="namHero" onclick="switchTab('FX')">
<div class="nam-badge" id="namBadge">NAM</div>
<div class="nam-name" id="namName">No Model Loaded</div>
<div class="nam-detail" id="namDetail"></div>
<div class="nam-led off" id="namLed"></div>
</div>
<div class="pedal-row" id="pedalRow">
<div class="pedal" id="btnAddPedal" onclick="showAddBlock()">
<div class="pd-add">+</div>
</div>
</div>
<div class="vol-row">
<span class="vol-label">Vol</span>
<div class="vol-track" id="volTrack">
<div class="vol-track-bg"><div class="vol-track-fill" id="volFill" style="width:80%"></div></div>
<div class="vol-track-thumb" id="volThumb" style="left:80%"></div>
</div>
<span class="vol-pct" id="volPct">80%</span>
</div>
</div>
<!-- Tab Bar -->
<div class="tab-bar" id="tabBar">
<button class="tab-btn active" data-view="main"><span class="tab-icon">🎛</span><span class="tab-label">FX</span></button>
<button class="tab-btn" data-view="presets"><span class="tab-icon">🎸</span><span class="tab-label">Presets</span></button>
<button class="tab-btn" data-view="snapshots"><span class="tab-icon">📸</span><span class="tab-label">Snapshots</span></button>
<button class="tab-btn" data-view="downloads"><span class="tab-icon"></span><span class="tab-label">Downloads</span></button>
<button class="tab-btn" id="tabTuner"><span class="tab-icon">🎵</span><span class="tab-label">Tuner</span></button>
<button class="tab-btn" data-view="settings"><span class="tab-icon"></span><span class="tab-label">Settings</span></button>
</div>
<!-- Footswitch -->
<div class="footswitch-row">
<button class="footswitch on" id="footswitch">BYpass</button>
</div>
<!-- Presets Overlay -->
<div class="overlay" id="overlayPresets">
<div class="overlay-hdr"><span class="overlay-title">🎸 Presets</span><button class="overlay-close" id="closePresets"></button></div>
<div class="overlay-body"><div class="preset-grid" id="presetGrid"></div></div>
</div>
<!-- Snapshots Overlay -->
<div class="overlay" id="overlaySnapshots">
<div class="overlay-hdr"><span class="overlay-title">📸 Snapshots</span><button class="overlay-close" id="closeSnapshots"></button></div>
<div class="overlay-body">
<div style="display:flex;gap:6px;margin-bottom:6px"><button class="sr-action primary" id="snapSaveCurrent" style="flex:1">💾 Save Current</button></div>
<div class="snap-grid" id="snapGrid"></div>
</div>
</div>
<!-- Downloads Overlay -->
<div class="overlay" id="overlayDownloads">
<div class="overlay-hdr"><span class="overlay-title">⬇ Downloads</span><button class="overlay-close" id="closeDownloads"></button></div>
<!-- Tab + filter nav -->
<div class="settings-nav">
<button class="sn-btn active" id="dlTabNam" onclick="switchDlTab('nam')">🎛 NAM</button>
<button class="sn-btn" id="dlTabIr" onclick="switchDlTab('ir')">📦 IR</button>
</div>
<div class="settings-nav" style="border-bottom:1px solid var(--border);padding-top:0">
<button class="sn-btn active" data-arch="" onclick="setArch(this,'')">All</button>
<button class="sn-btn" data-arch="nano" onclick="setArch(this,'nano')">Nano</button>
<button class="sn-btn" data-arch="feather" onclick="setArch(this,'feather')">Feather</button>
<button class="sn-btn" data-arch="standard" onclick="setArch(this,'standard')">Standard</button>
</div>
<div class="overlay-body" style="padding-top:4px">
<div style="display:flex;gap:6px;margin-bottom:6px">
<input id="dlSearch" placeholder="Search Tone3000..." style="flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font);font-size:11px;outline:none;min-height:36px">
<button class="sr-action primary" id="dlSearchBtn">🔍</button>
</div>
<div id="dlResults"></div>
</div>
</div>
<!-- Model Detail Overlay -->
<div class="overlay" id="overlayDetail">
<div class="overlay-hdr"><span class="overlay-title" id="dlDetailTitle">Model</span><button class="overlay-close"></button></div>
<div class="overlay-body">
<div id="dlDetailContent"></div>
</div>
</div>
<!-- Settings Overlay -->
<div class="overlay" id="overlaySettings">
<div class="overlay-hdr"><span class="overlay-title">⚙ Settings</span><button class="overlay-close" id="closeSettings"></button></div>
<!-- Settings section nav — stays fixed below header -->
<div class="settings-nav">
<button class="sn-btn active" data-section="network" onclick="scrollToSection('network')">⚙ Network</button>
<button class="sn-btn" data-section="audio" onclick="scrollToSection('audio')">🎛 Audio</button>
<button class="sn-btn" data-section="interface" onclick="scrollToSection('interface')">🎚 Interface</button>
<button class="sn-btn" data-section="bluetooth" onclick="scrollToSection('bluetooth')">🔵 Bluetooth</button>
<button class="sn-btn" data-section="system" onclick="scrollToSection('system')">⚡ System</button>
</div>
<div class="overlay-body">
<div class="setting-group" id="section-network"><div class="setting-group-title">Network</div>
<div class="setting-row"><span class="sr-label">🌐 Eth IP</span><span class="sr-value" id="netEthIp"></span></div>
<div class="setting-row"><span class="sr-label">🔗 MAC</span><span class="sr-value" id="netEthMac"></span></div>
<div class="setting-row"><span class="sr-label">🚪 Gateway</span><span class="sr-value" id="netGateway"></span></div>
<div class="setting-row"><span class="sr-label">🌐 DNS</span><span class="sr-value" id="netDns"></span></div>
<div class="setting-row"><span class="sr-label">🌍 IP Mode</span><span class="sr-value" id="netDhcpStatus">DHCP</span><button class="sr-action" id="netModeToggle">Static</button></div>
<div class="setting-row"><span class="sr-label">🔗 Hostname</span><span class="sr-value" id="netHostname">pedal</span></div>
<div class="setting-row"><span class="sr-label">📶 WiFi</span><span class="sr-value" id="wifiStatus"></span><button class="sr-action" id="wifiScanBtn">Scan</button></div>
<div id="wifiNetworks" class="wifi-list"></div>
<div class="wifi-connect-pw" id="wifiConnectPw" style="display:none"><input type="password" id="wifiPw" placeholder="WiFi password"><button id="wifiConnectBtn">Connect</button></div>
<div class="setting-row"><span class="sr-label">🔥 Hotspot</span><span class="sr-value" id="hotspotStatus">Off</span><button class="sr-action" id="hotspotToggle">Enable</button></div>
</div>
<div class="setting-group" id="section-audio"><div class="setting-group-title">Audio</div>
<div class="setting-row"><span class="sr-label">🎛 Sample Rate</span>
<select id="audioSampleRate" onchange="setAudioParams()" style="flex:1;max-width:140px;font-size:11px;padding:4px 8px;min-height:34px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="44100">44.1 kHz</option>
<option value="48000" selected>48 kHz</option>
<option value="96000">96 kHz</option>
<option value="192000">192 kHz</option>
</select>
</div>
<div class="setting-row"><span class="sr-label">📦 Buffer Size</span>
<select id="audioBufferSize" onchange="setAudioParams()" style="flex:1;max-width:140px;font-size:11px;padding:4px 8px;min-height:34px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="64">64</option>
<option value="128">128</option>
<option value="256" selected>256</option>
<option value="512">512</option>
<option value="1024">1024</option>
<option value="2048">2048</option>
</select>
</div>
<div class="setting-row"><span class="sr-label">🎸 Instrument</span><span class="sr-value" id="instrumentLabel">Guitar</span><button class="sr-action" id="instrumentCycleBtn" style="min-width:44px;padding:4px 10px">Cycle</button></div>
<div class="setting-row"><span class="sr-label">🔄 Channel Mode</span><span class="sr-value" id="channelModeLabel">Dual Mono</span><button class="sr-action" id="channelModeToggle">Toggle</button></div>
<div class="setting-row"><span class="sr-label">🔗 Routing</span><span class="sr-value" id="routingModeLabel">4CM</span><button class="sr-action" id="routingModeToggle">Toggle</button></div>
<div class="setting-row"><span class="sr-label">🎵 Backing Track</span>
<select id="backingSource" onchange="setBackingSource(this.value)" style="flex:1;max-width:160px;font-size:10px;padding:4px 6px;min-height:30px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="none">None</option>
</select>
</div>
</div>
<div class="setting-group" id="section-interface"><div class="setting-group-title">🎚 Audio Interface</div>
<div class="setting-row"><span class="sr-label">🔌 Input Device</span><select id="audioInputDevice" onchange="setAudioInputDevice(this.value)" style="flex:1;min-width:0;font-size:10px;padding:4px 6px;min-height:30px"><option value="">Loading...</option></select></div>
<div class="setting-row"><span class="sr-label">🔊 Output Device</span><select id="audioOutputDevice" onchange="setAudioOutputDevice(this.value)" style="flex:1;min-width:0;font-size:10px;padding:4px 6px;min-height:30px"><option value="">Loading...</option></select></div>
<div class="ch-card" id="ch1Card" style="background:var(--card2);border-radius:8px;padding:6px 8px;margin:4px 0;border:2px solid rgba(232,160,48,.25)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:11px;font-weight:700;color:var(--amber)">🔴 CH 1 — Input 1/L</span>
<button id="ch1Power" onclick="toggleChPower(0)" style="width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:12px;background:rgba(58,184,122,.2);border:1px solid rgba(58,184,122,.4);color:var(--green)"></button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:10px;color:var(--text2);flex-shrink:0">Instrument:</span>
<select id="ch1Instrument" onchange="setChInstrument(0,this.value)" style="flex:1;min-width:0;font-size:10px;padding:3px 6px;min-height:28px">
<option value="guitar">🎸 Guitar</option>
<option value="bass">🎸 Bass</option>
<option value="keys">🎹 Keys</option>
<option value="vocals">🎤 Vocals</option>
<option value="backing_tracks">📻 Backing</option>
</select>
</div>
<div style="font-size:9px;color:var(--text2);margin-top:2px;display:flex;align-items:center;justify-content:space-between">
<span>Output:</span>
<select id="ch1Output" onchange="setChOutput(1,this.value)" style="font-size:9px;padding:2px 4px;min-height:22px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="playback_1">Output 1 (L)</option>
<option value="playback_2">Output 2 (R)</option>
<option value="both" selected>Both (L+R)</option>
</select>
</div>
</div>
<div class="ch-card" id="ch2Card" style="background:var(--card2);border-radius:8px;padding:6px 8px;margin:4px 0;border:2px solid rgba(58,184,122,.25)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<span style="font-size:11px;font-weight:700;color:var(--green)">🟢 CH 2 — Input 2/R</span>
<button id="ch2Power" onclick="toggleChPower(1)" style="width:32px;height:32px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:12px;background:rgba(58,184,122,.2);border:1px solid rgba(58,184,122,.4);color:var(--green)"></button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:10px;color:var(--text2);flex-shrink:0">Instrument:</span>
<select id="ch2Instrument" onchange="setChInstrument(1,this.value)" style="flex:1;min-width:0;font-size:10px;padding:3px 6px;min-height:28px">
<option value="guitar">🎸 Guitar</option>
<option value="bass">🎸 Bass</option>
<option value="keys">🎹 Keys</option>
<option value="vocals">🎤 Vocals</option>
<option value="backing_tracks">📻 Backing</option>
</select>
</div>
<div style="font-size:9px;color:var(--text2);margin-top:2px;display:flex;align-items:center;justify-content:space-between">
<span>Output:</span>
<select id="ch2Output" onchange="setChOutput(2,this.value)" style="font-size:9px;padding:2px 4px;min-height:22px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-family:var(--font)">
<option value="playback_1">Output 1 (L)</option>
<option value="playback_2">Output 2 (R)</option>
<option value="both" selected>Both (L+R)</option>
</select>
</div>
</div>
<div style="font-size:9px;color:var(--text3);padding:2px 0">Dual Mono: each channel has independent presets. Same instrument = shared presets.</div>
</div>
<div class="setting-group" id="section-bluetooth"><div class="setting-group-title">Bluetooth</div>
<div class="setting-row"><span class="sr-label">🔵 Status</span><span class="sr-value" id="btStatus"></span></div>
<div class="setting-row"><span class="sr-label">🎛 MIDI</span><span class="sr-value" id="btMidiStatus">Off</span><button class="sr-action" id="btMidiToggle">Enable</button></div>
<div class="setting-row"><span class="sr-label">🔓 Discoverable</span><span class="sr-value" id="btDiscStatus">On</span><button class="sr-action" id="btDiscToggle">Disable</button></div>
</div>
<div class="setting-group" id="section-system"><div class="setting-group-title">System</div>
<div class="setting-row"><span class="sr-label">🔧 CPU</span><span class="sr-value" id="cpuLabel">0%</span></div>
<div class="setting-row"><span class="sr-label">🌡 Temp</span><span class="sr-value" id="sysTemp"></span></div>
<div class="setting-row"><span class="sr-label">⏱ Uptime</span><span class="sr-value" id="sysUptime"></span></div>
<div class="setting-row"><span class="sr-label">💾 Disk</span><span class="sr-value" id="sysDisk"></span></div>
<div class="setting-row"><span class="sr-label">🧠 RAM</span><span class="sr-value" id="sysMemory"></span></div>
<div class="setting-row"><span class="sr-label">↺ Reset</span><button class="sr-action danger" id="btnResetDefaults">Reset to Defaults</button></div>
<div class="setting-row"><span class="sr-label">🔄 Service</span><button class="sr-action danger" id="btnRestartSvc">Restart</button></div>
<div class="setting-row"><span class="sr-label">⏻ Reboot</span><button class="sr-action danger" id="btnReboot">Reboot</button></div>
</div>
</div>
</div>
<!-- Tuner -->
<div class="tuner-view" id="tunerView">
<div class="tuner-note" id="tunerNote"></div>
<div class="tuner-cents"><span style="font-size:10px;color:var(--text-dim)"></span><div class="tuner-cents-bar"><div class="tuner-cents-fill in-tune" id="tunerCentsFill"></div></div><span style="font-size:10px;color:var(--text-dim)"></span></div>
<div class="tuner-freq" id="tunerFreq">0.0 Hz</div>
<button class="tuner-exit" id="tunerExit">✕ Close Tuner</button>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ═══ API Client ═══
const API = {
_base:'',
async _fetch(path,opts={}){
const ac=new AbortController();
const to=setTimeout(()=>ac.abort(),opts.timeout||6000);
try{
const r=await fetch(this._base+path,{...opts,signal:ac.signal,headers:{'Content-Type':'application/json',...opts.headers}});
clearTimeout(to);
if(!r.ok)throw new Error(r.status+' '+r.statusText);
return r.status===204?null:r.json();
}catch(e){clearTimeout(to);if(e.name==='AbortError')throw new Error('timeout');throw e;}
},
getState:(ch)=>API._fetch('/api/state?channel='+(ch||'guitar')),
getTunerPitch:(ch)=>API._fetch('/api/tuner/pitch?channel='+(ch||'guitar'),{timeout:3000}),
bypassToggle:(ch)=>API._fetch('/api/bypass?channel='+(ch||'guitar'),{method:'POST'}),
tunerToggle:(ch,on)=>API._fetch('/api/tuner?channel='+(ch||'guitar'),{method:'POST',body:JSON.stringify({on})}),
setVolume:(ch,v)=>API._fetch('/api/volume?channel='+(ch||'guitar'),{method:'PUT',body:JSON.stringify({volume:v})}),
toggleBlock:(ch,id,en)=>API._fetch('/api/blocks?channel='+(ch||'guitar'),{method:'PATCH',body:JSON.stringify({block_id:id,enabled:en})}),
listPresets:(ch)=>API._fetch('/api/presets?channel='+(ch||'guitar')),
activatePreset:(ch,b,p)=>API._fetch('/api/presets/'+b+'/'+p+'/activate?channel='+(ch||'guitar'),{method:'POST'}),
listSnapshots:(ch)=>API._fetch('/api/snapshots?channel='+(ch||'guitar')),
saveSnapshot:(ch,s,data)=>API._fetch('/api/snapshots/'+s+'/save?channel='+(ch||'guitar'),{method:'POST',body:data?JSON.stringify(data):undefined}),
recallSnapshot:(ch,s)=>API._fetch('/api/snapshots/'+s+'/recall?channel='+(ch||'guitar'),{method:'POST'}),
wifiScan:()=>API._fetch('/api/wifi/scan',{timeout:15000}),
wifiStatus:()=>API._fetch('/api/wifi/status'),
wifiConnect:(s,p)=>API._fetch('/api/wifi/connect',{method:'POST',body:JSON.stringify({ssid:s,password:p}),timeout:20000}),
hotspotEnable:()=>API._fetch('/api/wifi/hotspot/enable',{method:'POST',timeout:15000}),
hotspotDisable:()=>API._fetch('/api/wifi/hotspot/disable',{method:'POST'}),
hotspotStatus:()=>API._fetch('/api/wifi/hotspot'),
btStatus:()=>API._fetch('/api/bluetooth/status'),
btPaired:()=>API._fetch('/api/bluetooth/paired'),
btMidiStatus:()=>API._fetch('/api/bluetooth/midi-status'),
btMidiEnable:()=>API._fetch('/api/bluetooth/midi-enable',{method:'POST'}),
btMidiDisable:()=>API._fetch('/api/bluetooth/midi-disable',{method:'POST'}),
btDiscoverableSet:(on)=>API._fetch('/api/bluetooth/discoverable',{method:'POST',body:JSON.stringify({on})}),
listAudioProfiles:()=>API._fetch('/api/audio/profiles'),
getAudioProfile:()=>API._fetch('/api/audio/profile'),
setAudioProfile:(p)=>{
if(typeof p==='string') return API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify({profile:p})});
return API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify(p)});
},
getChannelMode:()=>API._fetch('/api/channel-mode'),
setChannelMode:(m)=>API._fetch('/api/channel-mode',{method:'POST',body:JSON.stringify({channel_mode:m})}),
getRouting:(ch)=>API._fetch('/api/routing?channel='+(ch||'guitar')),
setRouting:(ch,data)=>API._fetch('/api/routing?channel='+(ch||'guitar'),{method:'POST',body:JSON.stringify(data)}),
saveSettings:(d)=>API._fetch('/api/settings',{method:'POST',body:JSON.stringify(d)}),
getSettings:()=>API._fetch('/api/settings'),
getSystem:()=>API._fetch('/api/system'),
getSystemLogs:(n)=>API._fetch('/api/system/logs?lines='+(n||50)),
setHostname:(h)=>API._fetch('/api/system/hostname',{method:'POST',body:JSON.stringify({hostname:h})}),
getNetwork:()=>API._fetch('/api/network'),
setNetworkStatic:(d)=>API._fetch('/api/network/static',{method:'POST',body:JSON.stringify(d),timeout:25000}),
setNetworkDhcp:()=>API._fetch('/api/network/dhcp',{method:'POST',timeout:25000}),
listAudioDevices:()=>API._fetch('/api/audio/devices'),
getDiagnostics:()=>API._fetch('/api/diagnostics'),
createBackup:()=>API._fetch('/api/backup',{method:'POST',timeout:35000}),
listBackups:()=>API._fetch('/api/backup/list'),
};
// ═══ State ═══
let state={channel:'guitar',connected:false,bypass:false,master_volume:0.8,blocks:[],routing_mode:'4cm',tuner_enabled:false,cpu_percent:0};
let currentChannel='guitar';
let channels=['guitar','bass','keys','vocals','backing_tracks'];
let presets=[];
let snapshots={};
let currentBank=0;
let pollTimer=null;
let tunerPollTimer=null;
let volDragging=false;
// ═══ Helpers ═══
const $=(s)=>document.querySelector(s);
const $$=(s)=>document.querySelectorAll(s);
function cycleInstrument(){
const idx=channels.indexOf(currentChannel);
currentChannel=channels[(idx+1)%channels.length];
document.getElementById('statusChannel').textContent=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
localStorage.setItem('pedalCurrentChannel',currentChannel);
toast(currentChannel);
}
// Loading overlay
function showLoading(msg){
let el=document.getElementById('loadingOverlay');
if(!el){
el=document.createElement('div');el.id='loadingOverlay';el.className='loading-overlay';
el.innerHTML='<div class=\"spinner\"></div><div class=\"load-msg\"></div>';
document.body.appendChild(el);
}
el.querySelector('.load-msg').textContent=msg||'Loading...';
el.style.display='flex';
}
function hideLoading(){
const el=document.getElementById('loadingOverlay');
if(el)el.style.display='none';
}
// Audio Interface helpers
async function loadAudioDevices(){
const inSel=document.getElementById('audioInputDevice');
const outSel=document.getElementById('audioOutputDevice');
if(!inSel||!outSel)return;
const devs=await API.listAudioDevices();
const opts=((devs&&devs.devices)||[]).map(d=>{
const id='hw:'+d.card+','+d.device;
return '<option value="'+id+'">'+d.name+' ('+d.desc+')</option>';
}).join('')||'<option value="">No devices</option>';
inSel.innerHTML=opts;
outSel.innerHTML=opts;
// Load current input/output from audio profile
try{
const p=await API.getAudioProfile();
if(p.input_device){
// Match by trailing device number (e.g. 'hw:USB,0' → card with device=0)
const devNum=String(p.input_device).split(',')[1];
const opts_arr=inSel.options;
for(let i=0;i<opts_arr.length;i++){if(opts_arr[i].value.endsWith(','+devNum)){inSel.selectedIndex=i;break;}}
}
if(p.output_device){
const devNum=String(p.output_device).split(',')[1];
const opts_arr=outSel.options;
for(let i=0;i<opts_arr.length;i++){if(opts_arr[i].value.endsWith(','+devNum)){outSel.selectedIndex=i;break;}}
}
}catch(e){}
}
async function setAudioInputDevice(id){
if(!id)return;
showLoading('Switching input...');
await API.saveSettings({input_device:id}).catch(()=>{});
hideLoading();
toast('Input: '+id);
}
async function setAudioOutputDevice(id){
if(!id)return;
showLoading('Switching output...');
await API.saveSettings({output_device:id}).catch(()=>{});
hideLoading();
toast('Output: '+id);
}
async function setChOutput(ch,port){
try{
await API._fetch('/api/channel-output',{method:'POST',body:JSON.stringify({ch,port})});
toast('CH'+ch+': '+port);
}catch(e){toast('Failed: '+e.message);}
}
async function loadChOutput(){
try{
const r=await API._fetch('/api/channel-output');
if(r.ch1){const el=document.getElementById('ch1Output');if(el)el.value=r.ch1;}
if(r.ch2){const el=document.getElementById('ch2Output');if(el)el.value=r.ch2;}
}catch(e){}
}
async function loadBackingSource(){
try{
const r=await API._fetch('/api/backing-source');
const sel=document.getElementById('backingSource');
if(!sel)return;
sel.innerHTML='';
(r.sources||[]).forEach(s=>{
const o=document.createElement('option');
o.value=s.id;o.textContent=s.name;
if(s.id===r.current)o.selected=true;
sel.appendChild(o);
});
}catch(e){}
}
async function setBackingSource(source){
if(!source)return;
try{
await API._fetch('/api/backing-source',{method:'POST',body:JSON.stringify({source})});
toast('Backing: '+source);
}catch(e){toast('Failed: '+e.message);}
}
function setChInstrument(idx,val){
const arr=JSON.parse(localStorage.getItem('chInstruments')||'["guitar","bass"]');
arr[idx]=val;
localStorage.setItem('chInstruments',JSON.stringify(arr));
API.saveSettings({['ch'+(idx+1)+'_instrument']:val}).catch(()=>{});
}
function toggleChPower(idx){
const btn=document.getElementById('ch'+(idx+1)+'Power');
const on=btn.textContent==='⚡';
btn.textContent=on?'⭕':'⚡';
btn.style.background=on?'rgba(160,160,188,.1)':'rgba(58,184,122,.2)';
btn.style.borderColor=on?'rgba(160,160,188,.2)':'rgba(58,184,122,.4)';
btn.style.color=on?'var(--text3)':'var(--green)';
API.bypassToggle(channels[idx]||'guitar').catch(()=>{});
}
function loadChInstrumentState(){
const arr=JSON.parse(localStorage.getItem('chInstruments')||'["guitar","bass"]');
['ch1Instrument','ch2Instrument'].forEach((id,i)=>{
const el=document.getElementById(id);
if(el)el.value=arr[i]||(i===0?'guitar':'bass');
});
}
function toast(msg,t){const e=$('#toast');e.textContent=msg;e.classList.add('show');clearTimeout(e._h);e._h=setTimeout(()=>e.classList.remove('show'),t||2000);}
// ═══ Overlay Controller ═══
function openOverlay(id){document.getElementById('overlay'+id).classList.add('open');}
function closeOverlay(id){document.getElementById('overlay'+id).classList.remove('open');}
document.querySelectorAll('.overlay-close').forEach(b=>{
b.addEventListener('click',function(){this.closest('.overlay').classList.remove('open');});
});
// ═══ Init Volume Slider ═══
function initVolSlider(){
const track=$('#volTrack'),fill=$('#volFill'),thumb=$('#volThumb'),pct=$('#volPct');
function setV(p){const v=Math.max(0,Math.min(1,p/100));fill.style.width=(v*100)+'%';thumb.style.left=(v*100)+'%';pct.textContent=Math.round(v*100)+'%';}
function upd(e){const r=track.getBoundingClientRect();const x=(e.touches?e.touches[0].clientX:e.clientX)-r.left;setV((x/r.width)*100);}
track.addEventListener('mousedown',(e)=>{volDragging=true;upd(e);});
track.addEventListener('touchstart',(e)=>{volDragging=true;upd(e);},{passive:true});
document.addEventListener('mousemove',(e)=>{if(volDragging){e.preventDefault();upd(e);}});
document.addEventListener('touchmove',(e)=>{if(volDragging)upd(e);},{passive:true});
document.addEventListener('mouseup',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});});
document.addEventListener('touchend',()=>{if(!volDragging)return;volDragging=false;API.setVolume(currentChannel,parseFloat($('#volFill').style.width)/100).catch(()=>{});});
}
// ═══ State Loading ═══
async function loadState(){
try{
const s=await API.getState(currentChannel);
if(!s)return;
state=s;
document.getElementById('statusChannel').textContent=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
document.getElementById('statusDot').className='status-dot '+(s.connected?'on':'off');
document.getElementById('statusPreset').textContent=(s.current_preset&&s.current_preset.name)||'—';
const vp=Math.round((s.master_volume||0)*100);
document.getElementById('statusVolPct').textContent=vp+'%';
document.getElementById('volFill').style.width=vp+'%';
document.getElementById('volThumb').style.left=vp+'%';
document.getElementById('volPct').textContent=vp+'%';
const fs=document.getElementById('footswitch');
if(s.bypass){fs.className='footswitch off';fs.textContent='MUTE';}
else{fs.className='footswitch on';fs.textContent='BYpass';}
document.getElementById('cpuLabel').textContent=(s.cpu_percent!=null?s.cpu_percent:'—')+'%';
renderNamHero();
const tb=document.getElementById('tabTuner');
if(s.tuner_enabled){tb.querySelector('.tab-label').textContent='Tuning';tb.classList.add('active');}
else{tb.querySelector('.tab-label').textContent='Tuner';tb.classList.remove('active');}
}catch(e){document.getElementById('statusDot').className='status-dot off';}
}
// ═══ FX Blocks ═══
const BLOCK_COLORS={nam_amp:'#E8A030',ir:'#4090E0',gate:'#50D090',compressor:'#E0E060',eq:'#A080E0',reverb:'#60C0E0',delay:'#80D0A0',distortion:'#E06060'};
const FX_ICONS={nam_amp:'🎛',nam:'🎛',overdrive:'⚡',distortion:'💥',delay:'⏳',reverb:'🌊',chorus:'🌀',compressor:'📈',eq:'📊',cabinet:'📦',ir:'📦',volume:'🔊',gate:'🚪',tremolo:'〰️',phaser:'🌀',flanger:'🌊',boost:'🚀',pitch:'🎵',filter:'🔀'};
const FX_COLORS={nam_amp:'#E0A040',nam:'#E0A040',overdrive:'#E8A030',distortion:'#E04040',delay:'#3090E0',reverb:'#40B0A0',chorus:'#50C080',compressor:'#D09040',eq:'#7080D0',cabinet:'#D07090',ir:'#D07090',volume:'#8888A0',gate:'#80A0C0',tremolo:'#C080D0',phaser:'#40B0D0',flanger:'#60A0E0',boost:'#E8A030',pitch:'#B080D0',filter:'#80C0A0'};
function renderNamHero(){
const hero=document.getElementById('namHero');
const nameEl=document.getElementById('namName');
const detailEl=document.getElementById('namDetail');
const ledEl=document.getElementById('namLed');
const badgeEl=document.getElementById('namBadge');
const blocks=state.blocks||[];
const namBlock=blocks.find(b=>b.fx_type==='nam_amp'||b.fx_type==='nam');
if(namBlock){
hero.className='nam-hero';
hero.style.borderColor=FX_COLORS[namBlock.fx_type]||'var(--border)';
badgeEl.textContent=(namBlock.fx_type||'NAM').toUpperCase();
badgeEl.style.background=(FX_COLORS[namBlock.fx_type]||'#E0A040')+'22';
badgeEl.style.color=FX_COLORS[namBlock.fx_type]||'#E0A040';
nameEl.textContent=namBlock.name||namBlock.nam_model_name||'NAM Model';
const enabled=namBlock.enabled!==false;
ledEl.className='nam-led '+(enabled?'on':'off');
ledEl.style.background=enabled?'var(--green)':'var(--red)';
const detailParts=[];
if(namBlock.nam_model_name)detailParts.push(namBlock.nam_model_name);
detailParts.push(enabled?'ON':'OFF');
if(state.nam_loaded)detailParts.push('loaded');
detailEl.textContent=detailParts.join(' · ')||'—';
}else{
hero.className='nam-hero empty';
hero.style.borderColor='';
nameEl.textContent='No NAM Model';
detailEl.textContent='Tap to add amp block';
ledEl.className='nam-led off';
badgeEl.textContent='NAM';
badgeEl.style='';
}
renderPedals();
}
function renderPedals(){
const row=document.getElementById('pedalRow');
const addBtn=document.getElementById('btnAddPedal');
row.innerHTML='';
row.appendChild(addBtn);
const nonNam=(state.blocks||[]).filter(b=>b.fx_type!=='nam_amp'&&b.fx_type!=='nam');
if(nonNam.length===0){
const empty=document.createElement('div');
empty.style.cssText='flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--text3);padding:0 8px;min-height:76px';
empty.textContent='No FX pedals — tap + to add';
row.insertBefore(empty,addBtn);
}
(state.blocks||[]).forEach((b,i)=>{
if(b.fx_type==='nam_amp'||b.fx_type==='nam')return; // NAM shown in hero
const en=b.enabled!==false&&!b.bypass;
const type=b.fx_type||'fx';
const name=b.name||b.fx_type||'Block';
const color=FX_COLORS[type]||'#E8A030';
const icon=FX_ICONS[type]||'🎛';
const pedal=document.createElement('div');
pedal.className='pedal '+(en?'on':'off');
pedal.style.borderColor=en?color:'var(--border)';
pedal.innerHTML='<div class="pd-led '+(en?'on':'off')+'" style="box-shadow:'+(en?'0 0 5px '+color:'none')+'"></div><div class="pd-icon">'+icon+'</div><div class="pd-label">'+name+'</div><div class="pd-switch '+(en?'on':'off')+'"></div>';
pedal.onclick=()=>{toggleBlock(i);};
row.insertBefore(pedal,addBtn);
});
}
async function toggleBlock(idx){
const blocks=state.blocks||[];
const b=blocks[idx];
if(!b)return;
const newEn=!(b.enabled!==false);
await API.toggleBlock(currentChannel,b.block_id||idx,newEn).catch(()=>{});
loadState();
}
function showAddBlock(){openOverlay('Downloads');}
// ═══ Presets ═══
async function loadPresets(){
try{
presets=await API.listPresets(currentChannel);
const grid=document.getElementById('presetGrid');
grid.innerHTML='';
const bank=(presets.banks||[])[currentBank];
const slots=bank?(bank.presets||[]):[];
for(let i=0;i<4;i++){
const p=slots[i];
const div=document.createElement('div');
const active=state.current_preset&&state.current_preset.bank===currentBank&&state.current_preset.program===i;
div.className='preset-card'+(active?' active':'');
if(p){
div.innerHTML='<div class="pc-num">'+currentBank+'.'+i+'</div><div class="pc-name">'+(p.name||'Preset')+'</div>';
div.onclick=()=>{API.activatePreset(currentChannel,currentBank,i).then(()=>{closeOverlay('Presets');toast('Loaded: '+(p.name||'Preset'));}).catch(e=>toast(e.message));};
}else{
div.innerHTML='<div class="pc-num">'+currentBank+'.'+i+'</div><div class="pc-name" style="color:var(--text-dim);font-weight:400">Empty</div>';
div.style.opacity='.35';
}
grid.appendChild(div);
}
}catch(e){}
}
// ═══ Snapshots ═══
async function loadSnapshots(){
try{
snapshots=await API.listSnapshots(currentChannel);
const grid=document.getElementById('snapGrid');
const slots=snapshots.snapshots||{};
const cur=snapshots.current_snapshot||0;
grid.innerHTML='';
for(let i=1;i<=8;i++){
const s=slots[i];
const div=document.createElement('div');
div.className='snap-slot'+(s?' used':'')+(cur===i?' current':'');
if(s){div.innerHTML='<div class="ss-num">'+i+'</div><div class="ss-name">'+(s.name||'Slot '+i)+'</div>';
div.onclick=()=>{API.recallSnapshot(currentChannel,i).then(()=>toast('Recalled: '+(s.name||'Slot '+i))).catch(e=>toast(e.message));};}
else{div.innerHTML='<div class="ss-num">'+i+'</div><div class="ss-name" style="color:var(--text-dim)">Empty</div>';}
grid.appendChild(div);
}
}catch(e){}
}
// ═══ Settings Loaders ═══
async function loadNetworkInfo(){
try{
const n=await API.getNetwork();
if(n.ethernet){
document.getElementById('netEthIp').textContent=n.ethernet.ip||'—';
document.getElementById('netEthMac').textContent=n.ethernet.mac||'—';
document.getElementById('netGateway').textContent=n.ethernet.gateway||'—';
}
if(n.dns)document.getElementById('netDns').textContent=n.dns.join(', ');
}catch(e){}
try{
const w=await API.wifiStatus();
if(w.ssid)document.getElementById('wifiStatus').textContent=w.ssid+(w.signal?' ('+w.signal+'dB)':'');
else if(w.connected)document.getElementById('wifiStatus').textContent='Connected';
else document.getElementById('wifiStatus').textContent='—';
}catch(e){}
}
async function loadSystemInfo(){
try{
const s=await API.getSystem();
if(s.cpu_temp_c)document.getElementById('sysTemp').textContent=s.cpu_temp_c+'°C';
document.getElementById('sysUptime').textContent=s.uptime||'—';
document.getElementById('sysDisk').textContent=s.disk?s.disk.pct+' ('+s.disk.avail+' free)':'—';
document.getElementById('sysMemory').textContent=s.memory_mb?Math.round(s.memory_mb.used/s.memory_mb.total*100)+'% ('+s.memory_mb.used+'/'+s.memory_mb.total+'MB)':'—';
document.getElementById('netHostname').textContent=s.hostname||'pedal';
}catch(e){}
}
async function loadAudioParams(){
try{
const p=await API.getAudioProfile();
const rate=p.rate||48000;
const period=p.period||256;
document.getElementById('audioSampleRate').value=String(rate);
document.getElementById('audioBufferSize').value=String(period);
const cm=await API.getChannelMode();
if(cm.channel_mode)document.getElementById('channelModeLabel').textContent=cm.channel_mode.replace(/-/g,' ').replace(/\b\w/g,l=>l.toUpperCase());
const rt=await API.getRouting(currentChannel);
if(rt.routing_mode)document.getElementById('routingModeLabel').textContent=rt.routing_mode.toUpperCase();
}catch(e){}
}
async function setAudioParams(){
const rate=parseInt(document.getElementById('audioSampleRate').value);
const period=parseInt(document.getElementById('audioBufferSize').value);
const btn=document.querySelector('.sn-btn[data-section="audio"]');
if(btn)btn.textContent='⏳ Applying...';
try{
await API._fetch('/api/audio/profile',{method:'POST',body:JSON.stringify({period,rate}),timeout:15000});
toast('Audio: '+rate+'Hz / '+period+'fr');
loadAudioParams();
}catch(e){toast('Failed: '+e.message);}
if(btn)btn.textContent='🎛 Audio';
}
async function loadBtStatus(){
try{
const st=await API.btStatus();
document.getElementById('btStatus').textContent=st.powered?'Powered On':'Powered Off';
document.getElementById('btDiscStatus').textContent=st.discoverable?'On':'Off';
document.getElementById('btDiscToggle').textContent=st.discoverable?'Disable':'Enable';
}catch(e){}
}
async function loadBtMidi(){
try{
const st=await API.btMidiStatus();
const r=st.running||st.enabled||false;
document.getElementById('btMidiStatus').textContent=r?'Running':'Off';
document.getElementById('btMidiToggle').textContent=r?'Disable':'Enable';
}catch(e){}
}
// Settings section nav — scroll to section and highlight active button
function scrollToSection(id){
const el=document.getElementById('section-'+id);
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
document.querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active'));
const btn=document.querySelector('.sn-btn[data-section="'+id+'"]');
if(btn) btn.classList.add('active');
}
async function loadHotspotStatus(){
try{
const hs=await API.hotspotStatus();
const a=hs.active||false;
document.getElementById('hotspotStatus').textContent=a?'On':'Off';
document.getElementById('hotspotToggle').textContent=a?'Disable':'Enable';
document.getElementById('hotspotToggle').className='sr-action'+(a?' danger':'');
}catch(e){}
}
async function doWifiScan(){
const list=document.getElementById('wifiNetworks');
list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">Scanning...</div>';
try{
const r=await API.wifiScan();
const nets=r.networks||[];
list.innerHTML='';
if(nets.length===0){list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">No networks found</div>';return;}
nets.forEach(n=>{
const item=document.createElement('div');
item.className='wifi-item';
const sec=n.secured!==false;
item.innerHTML='<span class="wi-ssid">'+(n.ssid||n.name||'Unknown')+'</span>'+(sec?'<span style="font-size:8px;color:var(--text-dim)">🔒</span>':'<span style="font-size:8px;color:var(--green)">Open</span>')+'<span style="font-size:8px;color:var(--text-dim)">'+(n.signal||'')+'dB</span>';
item.onclick=()=>{
if(sec){
const pwDiv=document.getElementById('wifiConnectPw');
pwDiv.style.display='flex';
document.getElementById('wifiPw').value='';
document.getElementById('wifiPw').focus();
document.getElementById('wifiConnectBtn').onclick=async()=>{
const pw=document.getElementById('wifiPw').value;
if(!pw){toast('Enter password');return;}
pwDiv.style.display='none';
try{await API.wifiConnect(n.ssid||n.name,pw);toast('Connected!');setTimeout(doWifiScan,3000);}catch(e){toast(e.message);}
};
}else{
API.wifiConnect(n.ssid||n.name,'').then(()=>toast('Connected!')).catch(e=>toast(e.message));
}
};
list.appendChild(item);
});
document.getElementById('wifiStatus').textContent=nets.length+' networks';
}catch(e){list.innerHTML='<div style="text-align:center;padding:10px;color:var(--text-dim);font-size:11px">Scan failed</div>';}
}
// ═══ Tuner ═══
function startTunerPolling(){stopTunerPolling();if(state.tuner_enabled)tunerPollTimer=setInterval(pollTuner,150);}
function stopTunerPolling(){if(tunerPollTimer){clearInterval(tunerPollTimer);tunerPollTimer=null;}}
async function pollTuner(){
if(!state.tuner_enabled)return;
try{
const d=await API.getTunerPitch(currentChannel);
if(!d)return;
document.getElementById('tunerNote').textContent=d.note||'—';
const fill=document.getElementById('tunerCentsFill');
const c=d.cents||0;
if(Math.abs(c)<3){fill.className='tuner-cents-fill in-tune';fill.style.left='calc(50% - 3px)';fill.style.width='6px';}
else if(c>0){fill.className='tuner-cents-fill sharp';fill.style.left='calc(50% + '+Math.min(c,50)*1.2+'px)';fill.style.width=Math.min(Math.abs(c),50)+'px';}
else{fill.className='tuner-cents-fill flat';fill.style.left='calc(50% - '+Math.min(Math.abs(c),50)*1.2+'px)';fill.style.width=Math.min(Math.abs(c),50)+'px';}
document.getElementById('tunerFreq').textContent=(d.frequency||0).toFixed(1)+' Hz';
}catch(e){}
}
// ═══ Main Poll Loop ═══
function startPolling(){stopPolling();loadState();pollTimer=setInterval(loadState,2000);}
function stopPolling(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
// ═══ Init! ═══
function init(){
// Restore saved channel
const savedChannel=localStorage.getItem('pedalCurrentChannel');
if(savedChannel && channels.includes(savedChannel)) currentChannel=savedChannel;
initVolSlider();
// Footswitch
document.getElementById('footswitch').onclick=()=>API.bypassToggle(currentChannel).catch(()=>{});
// Tab bar navigation
document.querySelectorAll('.tab-btn[data-view]').forEach(btn=>{
btn.onclick=()=>{
const view=btn.dataset.view;
if(view==='main'){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');}
else if(view==='settings'){openOverlay('Settings');loadNetworkInfo();loadSystemInfo();loadAudioParams();loadAudioDevices();loadChOutput();loadBackingSource();loadHotspotStatus();loadBtStatus();loadBtMidi();return;}
else if(view==='presets'){openOverlay('Presets');loadPresets();}
else if(view==='snapshots'){openOverlay('Snapshots');loadSnapshots();}
else if(view==='downloads')openOverlay('Downloads');
};
});
// Status bar: settings → open settings overlay
document.getElementById('btnSettings').onclick=()=>{
openOverlay('Settings');
loadNetworkInfo();
loadSystemInfo();
loadAudioParams();
loadAudioDevices();
loadChOutput();
loadBackingSource();
loadHotspotStatus();
loadBtStatus();
loadBtMidi();
};
// Close all overlays
document.querySelectorAll('.overlay').forEach(o=>{o.onclick=function(e){if(e.target===this)this.classList.remove('open');};});
// Settings: Toggles
document.getElementById('instrumentCycleBtn').onclick=()=>{
const idx=channels.indexOf(currentChannel);
currentChannel=channels[(idx+1)%channels.length];
const displayName=currentChannel.charAt(0).toUpperCase()+currentChannel.slice(1).replace('_',' ');
document.getElementById('instrumentLabel').textContent=displayName;
document.getElementById('statusChannel').textContent=displayName;
localStorage.setItem('pedalCurrentChannel',currentChannel);
toast(currentChannel);
loadState();
};
document.getElementById('channelModeToggle').onclick=async()=>{
const cur=document.getElementById('channelModeLabel').textContent.toLowerCase();
const next=cur.includes('dual')?'stereo':'dual-mono';
try{await API.setChannelMode(next);loadAudioParams();toast('Mode: '+next);}catch(e){toast(e.message);}
};
document.getElementById('routingModeToggle').onclick=async()=>{
const cur=document.getElementById('routingModeLabel').textContent;
try{await API.setRouting(currentChannel,{routing_mode:cur==='MONO'?'4cm':'mono'});loadAudioParams();}catch(e){toast(e.message);}
};
document.getElementById('netModeToggle').onclick=async()=>{
const lbl=document.getElementById('netDhcpStatus');
if(lbl.textContent==='DHCP'){
const ip=prompt('Enter static IP:','192.168.0.100');
if(!ip)return;
const gw=prompt('Gateway:','192.168.0.1');
try{await API.setNetworkStatic({ip,netmask:'24',gateway:gw});lbl.textContent='Static';toast('Static: '+ip);}catch(e){toast(e.message);}
}else{
try{await API.setNetworkDhcp();lbl.textContent='DHCP';toast('DHCP enabled');}catch(e){toast(e.message);}
}
};
document.getElementById('wifiScanBtn').onclick=doWifiScan;
document.getElementById('hotspotToggle').onclick=async()=>{
const on=document.getElementById('hotspotToggle').textContent==='Disable';
try{if(on)await API.hotspotDisable();else await API.hotspotEnable();loadHotspotStatus();toast(on?'Hotspot off':'Hotspot on');}catch(e){toast(e.message);}
};
document.getElementById('btMidiToggle').onclick=async()=>{
const on=document.getElementById('btMidiToggle').textContent==='Disable';
try{if(on)await API.btMidiDisable();else await API.btMidiEnable();loadBtMidi();toast('MIDI '+(on?'off':'on'));}catch(e){toast(e.message);}
};
document.getElementById('btDiscToggle').onclick=async()=>{
const on=document.getElementById('btDiscToggle').textContent==='Disable';
try{await API.btDiscoverableSet(!on);loadBtStatus();toast(on?'Hidden':'Visible');}catch(e){toast(e.message);}
};
// System
document.getElementById('btnResetDefaults').onclick=async()=>{
if(!confirm('Reset all to factory defaults?'))return;
toast('Resetting...');
try{
await API.saveSettings({channel_mode:'dual-mono',brightness:8,tap_tempo:120});
await API.setRouting(currentChannel,{routing_mode:'mono'}).catch(()=>{});
toast('Defaults restored');
setTimeout(()=>{closeOverlay('Settings');loadAudioParams();},1000);
}catch(e){toast('Failed: '+e.message);}
};
document.getElementById('btnRestartSvc').onclick=()=>{toast('Restarting service...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_restart:true})}).catch(()=>{});};
document.getElementById('btnReboot').onclick=()=>{if(confirm('Reboot pedal?')){toast('Rebooting...');fetch('/api/settings',{method:'POST',body:JSON.stringify({_reboot:true})}).catch(()=>{});}};
// Tuner
document.getElementById('tabTuner').onclick=async()=>{
state.tuner_enabled=!state.tuner_enabled;
if(state.tuner_enabled){document.getElementById('tunerView').classList.add('open');startTunerPolling();}
else{document.getElementById('tunerView').classList.remove('open');stopTunerPolling();}
API.tunerToggle(currentChannel,state.tuner_enabled).catch(()=>{});
};
document.getElementById('tunerExit').onclick=()=>{
state.tuner_enabled=false;
document.getElementById('tunerView').classList.remove('open');
stopTunerPolling();
API.tunerToggle(currentChannel,false).catch(()=>{});
};
// Snapshots save
document.getElementById('snapSaveCurrent').onclick=async()=>{
const slots=snapshots.snapshots||{};
let slot=1;
for(let i=1;i<=8;i++){if(!slots[i]){slot=i;break;}}
try{await API.saveSnapshot(currentChannel,slot);toast('Saved to slot '+slot);loadSnapshots();}catch(e){toast(e.message);}
};
// Downloads - state
let dlTab='nam', dlArch='';
function switchDlTab(tab){
dlTab=tab;
document.getElementById('dlTabNam').className='sn-btn'+(tab==='nam'?' active':'');
document.getElementById('dlTabIr').className='sn-btn'+(tab==='ir'?' active':'');
const archRow=document.querySelectorAll('#overlayDownloads > .settings-nav')[1];
if(archRow)archRow.style.display=tab==='nam'?'flex':'none';
doDlSearch();
}
function setArch(btn,arch){
document.querySelectorAll('#overlayDownloads .settings-nav')[1].querySelectorAll('.sn-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
dlArch=arch;
doDlSearch();
}
async function doDlSearch(){
const q=document.getElementById('dlSearch').value.trim();
const res=document.getElementById('dlResults');
res.innerHTML='<div style="text-align:center;padding:16px"><span class="spinner"></span></div>';
try{
const endpoint=dlTab==='nam'?'/api/models/tonedownload/search':'/api/irs/tonedownload/search';
const params='?q='+encodeURIComponent(q)+(dlArch?'&arch='+dlArch:'')+'&page=0';
const data=await API._fetch(endpoint+params);
const items=data.results||[];
res.innerHTML='';
if(items.length===0){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim);font-size:11px">No results'+(q?'':' \u2014 tap Search or type a query')+'</div>';return;}
items.forEach(item=>{
const d=document.createElement('div');
d.className='preset-card';
d.style.cursor='pointer';
const meta=[];
if(item.size_display)meta.push(item.size_display);
if(item.architecture)meta.push(item.architecture);
if(item.tone_gear&&item.tone_gear!=='nam')meta.push(item.tone_gear);
d.innerHTML='<div class="pc-name">'+(item.name||'Unknown')+'</div><div class="pc-num">'+(item.author||'')+(item.pack_name?' \u00b7 '+item.pack_name:'')+'</div><div style="font-size:8px;color:var(--text-dim);margin-top:2px">'+meta.join(' \u00b7 ')+'</div>';
d.onclick=()=>showDlDetail(item);
res.appendChild(d);
});
}catch(e){res.innerHTML='<div style="text-align:center;padding:16px;color:var(--text-dim)">Error</div>';}
}
function showDlDetail(item){
const c=document.getElementById('dlDetailContent');
const isIr=dlTab==='ir';
let html='<div style="margin-bottom:8px">';
if(item.thumbnail)html+='<img src="'+item.thumbnail+'" style="width:100%;border-radius:8px;margin-bottom:6px;max-height:160px;object-fit:cover">';
html+='<div style="font-size:14px;font-weight:700;margin-bottom:4px">'+(item.name||'Unknown')+'</div>';
html+='<div style="font-size:11px;color:var(--text-sec);margin-bottom:6px">by '+(item.author||'Unknown')+'</div>';
if(item.pack_name)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Pack: '+item.pack_name+'</div>';
if(item.architecture)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Arch: '+item.architecture+'</div>';
if(item.size_display)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Size: '+item.size_display+'</div>';
if(item.tone_gear)html+='<div style="font-size:10px;color:var(--text-dim);margin-bottom:4px">Gear: '+item.tone_gear+'</div>';
if(item.tone_description)html+='<div style="font-size:10px;color:var(--text2);margin-top:6px;padding:6px 8px;background:var(--surface);border-radius:6px;border:1px solid var(--border);line-height:1.4">'+(item.tone_description||'')+'</div>';
html+='</div>';
html+='<button class="sr-action primary" id="dlDetailBtn" style="width:100%;min-height:42px;font-size:13px">Download'+(isIr?' IR':'')+'</button>';
c.innerHTML=html;
document.getElementById('dlDetailTitle').textContent=(item.name||'Model');
document.getElementById('overlayDetail').classList.add('open');
document.getElementById('dlDetailBtn').onclick=async()=>{
const btn=document.getElementById('dlDetailBtn');
btn.textContent='...';
try{
const endpoint=isIr?'/api/irs/tonedownload/install':'/api/models/tonedownload/install';
await API._fetch(endpoint,{method:'POST',body:JSON.stringify({id:item.id,download_url:item.download_url,name:item.name,filename:item.filename,size:item.size,author:item.author,author_avatar:item.author_avatar,thumbnail:item.thumbnail,tone_description:item.tone_description,pack_name:item.pack_name,architecture:item.architecture,created_at:item.created_at}),timeout:60000});
toast('Downloaded!');
btn.textContent='Downloaded';
btn.className='sr-action';
}catch(e){toast(e.message);btn.textContent='Retry';}
};
}
// Wire downloads search
document.getElementById('dlSearchBtn').onclick=doDlSearch;
document.getElementById('dlSearch').onkeydown=(e)=>{if(e.key==='Enter')doDlSearch();};
// Start polling
startPolling();
loadPresets();
loadSnapshots();
}
document.addEventListener('DOMContentLoaded',init);
</script>
</body>
</html>