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 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 const running = state.timerActive; btnRun.disabled = running; durInput.disabled = running; 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) { ring.style.strokeDashoffset = CIRC; cd.textContent = '⏱'; cd.className = 'countdown idle'; 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'); } // 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 }); } });