const CIRC = 2 * Math.PI * 52; // ~326.73 const ring = document.getElementById('ring'); const cd = document.getElementById('countdown'); const label = document.getElementById('label'); const badge = document.getElementById('badge'); const badgeTxt = document.getElementById('badge-text'); const glow = document.getElementById('glow'); const durInput = document.getElementById('dur-input'); const btnRun = document.getElementById('btn-run'); const btnLearn = document.getElementById('btn-learn'); const btnOpen = document.getElementById('btn-open'); const btnClose = document.getElementById('btn-close'); function fmt(ms) { if (ms <= 0) return '0.0'; const totalSec = ms / 1000; if (totalSec < 60) return totalSec.toFixed(1); const m = Math.floor(totalSec / 60); const s = Math.floor(totalSec % 60); return m + ':' + String(s).padStart(2, '0'); } function fmtDur(ms) { const s = (ms / 1000).toFixed(1); return s + 's'; } let state = null; let serverOffset = 0; // server time - client time async function poll() { try { const r = await fetch('/api/status'); state = await r.json(); // Compute clock offset so countdown works even with clock skew serverOffset = state.now - Date.now(); } catch (e) { /* retry next tick */ } } function render() { if (!state) return; // Sync duration input if user isn't editing it if (document.activeElement !== durInput) { const secs = Math.round(state.storedDuration / 1000); if (durInput.value !== String(secs)) durInput.value = secs; } // Disable controls while timer is running or learning const running = state.timerActive; const learning = state.countMode; btnRun.disabled = running || learning; durInput.disabled = running || learning; btnOpen.disabled = running || learning; btnClose.disabled = running || learning; // Learn button toggles label and action if (learning) { btnLearn.textContent = '⏹ Stop Learn'; btnLearn.onclick = doStopLearn; btnLearn.classList.add('active-learn'); } else { btnLearn.textContent = '📐 Learn'; btnLearn.onclick = doLearn; btnLearn.classList.remove('active-learn'); } btnLearn.disabled = running; // can't start learn while timer runs if (state.timerActive && state.timerStartedAt && state.timerEndsAt) { const total = state.timerEndsAt - state.timerStartedAt; const serverNow = Date.now() + serverOffset; const remaining = Math.max(0, state.timerEndsAt - serverNow); const progress = total > 0 ? remaining / total : 0; ring.style.strokeDashoffset = CIRC * (1 - progress); cd.textContent = fmt(remaining); cd.className = 'countdown'; label.textContent = 'REMAINING'; badge.className = 'badge running'; badgeTxt.textContent = 'Timer Running'; glow.classList.add('active'); } else if (state.countMode) { const elapsed = state.countStart ? Math.max(0, (Date.now() + serverOffset) - state.countStart) : 0; ring.style.strokeDashoffset = CIRC; cd.textContent = fmt(elapsed); cd.className = 'countdown'; label.textContent = 'LEARNING'; badge.className = 'badge counting'; badgeTxt.textContent = 'Count Mode'; glow.classList.remove('active'); } else { ring.style.strokeDashoffset = CIRC; cd.textContent = '—'; cd.className = 'countdown idle'; label.textContent = 'IDLE'; badge.className = 'badge closed'; badgeTxt.textContent = 'Idle'; glow.classList.remove('active'); } } // Quindar tones via Web Audio API let audioCtx = null; let endBeeped = false; let wasTimerActive = false; function playTone(freq) { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const t = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0.3, t); gain.gain.setValueAtTime(0.3, t + 0.24); gain.gain.linearRampToValueAtTime(0, t + 0.25); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(t); osc.stop(t + 0.25); } function checkBeep() { if (!state) return; const active = state.timerActive && state.timerStartedAt && state.timerEndsAt; // Start tone (2525 Hz) when timer begins if (active && !wasTimerActive) { playTone(2525); endBeeped = false; } // End tone (2475 Hz) at 10s remaining if (active) { const serverNow = Date.now() + serverOffset; const remaining = Math.max(0, state.timerEndsAt - serverNow); if (remaining <= 10000 && remaining > 0 && !endBeeped) { endBeeped = true; playTone(2475); } } else { endBeeped = false; } wasTimerActive = !!active; } // ── Wake Lock ── let wakeLock = null; let lockAcquired = false; async function requestWakeLock() { try { if ('wakeLock' in navigator && !wakeLock) { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; }); } } catch (e) { console.error('Wake Lock error:', e); } } function releaseWakeLock() { if (wakeLock) { wakeLock.release().catch(() => { }); wakeLock = null; } } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && lockAcquired) requestWakeLock(); }); function checkWakeLock() { if (!state) return; const active = state.timerActive || state.countMode; if (active && !lockAcquired) { requestWakeLock(); lockAcquired = true; } else if (!active && lockAcquired) { releaseWakeLock(); lockAcquired = false; } } // Poll server every 2s, render locally at 20fps for smooth countdown poll(); setInterval(poll, 2000); setInterval(() => { render(); checkBeep(); checkWakeLock(); }, 50); // ── Control actions ── async function apiPost(path, body = {}) { try { const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); if (!d.ok) console.error('API error:', d.error); await poll(); } catch (e) { console.error('API call failed:', e); } } async function doRun() { // Save duration first if changed const secs = parseInt(durInput.value, 10); if (secs > 0 && secs <= 300) { await apiPost('/api/duration', { seconds: secs }); } await apiPost('/api/run'); } function doOpen() { apiPost('/api/open'); } function doClose() { apiPost('/api/close'); } async function doLearn() { await apiPost('/api/learn'); } async function doStopLearn() { await apiPost('/api/stop-learn'); } // Also save duration on Enter or blur durInput.addEventListener('change', () => { const secs = parseInt(durInput.value, 10); if (secs > 0 && secs <= 300) { apiPost('/api/duration', { seconds: secs }); } }); // ── Button online status ── const deviceDot = document.getElementById('device-dot'); const deviceState = document.getElementById('device-state'); const valveDot = document.getElementById('valve-dot'); const valveState = document.getElementById('valve-state'); function updateDeviceWidget(dot, stateEl, online) { if (online === true) { dot.className = 'device-dot online'; stateEl.className = 'device-state online'; stateEl.textContent = 'Online'; } else if (online === false) { dot.className = 'device-dot offline'; stateEl.className = 'device-state offline'; stateEl.textContent = 'Offline'; } else { dot.className = 'device-dot'; stateEl.className = 'device-state'; stateEl.textContent = 'Unknown'; } } async function pollButtonOnline() { try { const r = await fetch('/api/button-online'); const d = await r.json(); updateDeviceWidget(deviceDot, deviceState, d.online); } catch { updateDeviceWidget(deviceDot, deviceState, null); } } async function pollValveOnline() { try { const r = await fetch('/api/valve-online'); const d = await r.json(); updateDeviceWidget(valveDot, valveState, d.online); } catch { updateDeviceWidget(valveDot, valveState, null); } } pollButtonOnline(); pollValveOnline(); setInterval(pollButtonOnline, 10000); setInterval(pollValveOnline, 10000);