|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<title>Timer</title> |
|
<style> |
|
body { |
|
margin: 0; |
|
background: #111; /* default, can be overridden by JS */ |
|
color: #fff; |
|
font-family: system-ui, sans-serif; |
|
} |
|
|
|
#countdown { |
|
font-size: 200px; |
|
color: #fdc331; /* default fill, can be overridden by JS */ |
|
text-align: center; |
|
margin-top: 15vh; |
|
text-shadow: 0 0 5px #000, 0 0 10px #000, 0 0 15px #000; /* default outline */ |
|
} |
|
|
|
/* bottom-center hint */ |
|
#config-hint { |
|
position: fixed; |
|
bottom: 10px; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
font-size: 14px; |
|
color: #aaa; |
|
opacity: 0.8; |
|
pointer-events: none; |
|
} |
|
|
|
/* Config panel: hidden by default, appears top-right when active */ |
|
#config-panel { |
|
position: fixed; |
|
top: 20px; |
|
right: 20px; |
|
font-size: 14px; |
|
background: rgba(0,0,0,0.85); |
|
padding: 10px 14px; |
|
border-radius: 8px; |
|
color: #fff; |
|
display: none; /* toggled via JS */ |
|
flex-direction: column; |
|
gap: 8px; |
|
z-index: 5000; |
|
} |
|
|
|
#config-panel-row { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
select, button, input[type="color"], input[type="number"] { |
|
font-size: 14px; |
|
} |
|
|
|
/* Acknowledge button container, bottom-right */ |
|
#ack-container { |
|
position: fixed; |
|
bottom: 20px; |
|
right: 20px; |
|
font-size: 16px; |
|
background: rgba(0,0,0,0.7); |
|
padding: 8px 14px; |
|
border-radius: 999px; |
|
color: #fff; |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
|
|
opacity: 0; |
|
pointer-events: none; |
|
transition: opacity 0.3s ease; |
|
z-index: 4000; |
|
} |
|
|
|
#ack-btn[disabled] { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
#unlock-overlay { |
|
position: fixed; |
|
top: 0; left: 0; |
|
width: 100%; height: 100%; |
|
background: rgba(0,0,0,0.8); |
|
color: white; |
|
font-size: 40px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
cursor: pointer; |
|
z-index: 9999; |
|
text-align: center; |
|
padding: 20px; |
|
box-sizing: border-box; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<div id="unlock-overlay"> |
|
🔊 Click anywhere to enable sound<br> |
|
<span style="font-size:18px;opacity:0.8;">(Required by your browser)</span> |
|
</div> |
|
|
|
<h1 id="countdown">13:37</h1> |
|
|
|
<div id="config-hint"> |
|
Press <b>C</b> to configure |
|
</div> |
|
|
|
<!-- Config panel (shown when user presses C) --> |
|
<div id="config-panel"> |
|
<div id="config-panel-row"> |
|
<strong>Configuration</strong> |
|
<button type="button" onclick="hideConfig()">✕ Close</button> |
|
</div> |
|
|
|
<div id="config-panel-row"> |
|
<label for="sound-select">Sound:</label> |
|
<select id="sound-select"> |
|
<option value="beep">Beep</option> |
|
<option value="chime">Chime</option> |
|
<option value="alarm" selected>Alarm</option> |
|
</select> |
|
<button type="button" onclick="previewSound()">▶ Preview</button> |
|
</div> |
|
|
|
<div id="config-panel-row"> |
|
<label for="font-select">Font:</label> |
|
<select id="font-select"> |
|
<option value="digital" selected>Digital (monospace)</option> |
|
<option value="rounded">Rounded UI</option> |
|
<option value="system">System UI</option> |
|
<option value="bold">Big Bold</option> |
|
<option value="serif">Classic Serif</option> |
|
</select> |
|
</div> |
|
|
|
<!-- Color pickers --> |
|
<div id="config-panel-row"> |
|
<label for="fill-color">Fill:</label> |
|
<input type="color" id="fill-color" value="#fdc331"> |
|
|
|
<label for="outline-color">Outline:</label> |
|
<input type="color" id="outline-color" value="#000000"> |
|
|
|
<label for="bg-color">Background:</label> |
|
<input type="color" id="bg-color" value="#111111"> |
|
</div> |
|
|
|
<!-- NEW: Alarm duration --> |
|
<div id="config-panel-row"> |
|
<label for="alarm-duration">Alarm duration (sec):</label> |
|
<input type="number" id="alarm-duration" min="1" max="600" step="1" value="30" style="width:70px;"> |
|
</div> |
|
|
|
<div style="font-size:12px;opacity:0.8;"> |
|
Tip: Press <b>C</b> again to close. |
|
</div> |
|
</div> |
|
|
|
<!-- Acknowledge button (only shows while alarm active) --> |
|
<div id="ack-container"> |
|
<button type="button" id="ack-btn" onclick="acknowledgeAlarm()" disabled> |
|
Acknowledge |
|
</button> |
|
</div> |
|
|
|
<script> |
|
const backend_url = "https://poketwitch.bframework.de/"; |
|
let soundEnabled = false; |
|
let audioCtx = null; |
|
|
|
// alarm control |
|
let alarmActive = false; |
|
let alarmInterval = null; |
|
let alarmStopTimeout = null; |
|
|
|
// config visibility |
|
let configVisible = false; |
|
|
|
// configurable alarm duration (seconds) |
|
let alarmDurationSeconds = 30; |
|
|
|
function pad(n) { |
|
return n.toString().padStart(2, '0'); |
|
} |
|
|
|
function ensureAudioContext() { |
|
if (!audioCtx) { |
|
const AC = window.AudioContext || window.webkitAudioContext; |
|
audioCtx = new AC(); |
|
} |
|
if (audioCtx.state === 'suspended') { |
|
audioCtx.resume(); |
|
} |
|
} |
|
|
|
// 🔊 Simple synth-based sounds, no files |
|
function playAlarmBurst(kind) { |
|
if (!soundEnabled) return; |
|
ensureAudioContext(); |
|
const ctx = audioCtx; |
|
const now = ctx.currentTime; |
|
|
|
function makeBeep(freq, startOffset, duration, type = 'sine', volume = 0.2) { |
|
const osc = ctx.createOscillator(); |
|
const gain = ctx.createGain(); |
|
osc.type = type; |
|
osc.frequency.setValueAtTime(freq, now + startOffset); |
|
osc.connect(gain); |
|
gain.connect(ctx.destination); |
|
|
|
const t0 = now + startOffset; |
|
gain.gain.setValueAtTime(0, t0); |
|
gain.gain.linearRampToValueAtTime(volume, t0 + 0.01); |
|
gain.gain.linearRampToValueAtTime(0, t0 + duration); |
|
|
|
osc.start(t0); |
|
osc.stop(t0 + duration + 0.05); |
|
} |
|
|
|
if (kind === 'beep') { |
|
makeBeep(880, 0.0, 0.15, 'square', 0.2); |
|
} else if (kind === 'chime') { |
|
makeBeep(660, 0.0, 0.25, 'sine', 0.15); |
|
makeBeep(990, 0.22, 0.35, 'sine', 0.15); |
|
} else if (kind === 'alarm') { |
|
makeBeep(800, 0.0, 0.15, 'square', 0.25); |
|
makeBeep(800, 0.25, 0.15, 'square', 0.25); |
|
makeBeep(800, 0.50, 0.15, 'square', 0.25); |
|
} else { |
|
makeBeep(880, 0.0, 0.2, 'square', 0.2); |
|
} |
|
} |
|
|
|
// 🚨 Start a repeating alarm that lasts up to alarmDurationSeconds |
|
function startAlarm(kind) { |
|
if (!soundEnabled) return; |
|
if (alarmActive) return; |
|
|
|
alarmActive = true; |
|
const ackBtn = document.getElementById('ack-btn'); |
|
const ackContainer = document.getElementById('ack-container'); |
|
|
|
ackBtn.disabled = false; |
|
ackContainer.style.opacity = "1"; |
|
ackContainer.style.pointerEvents = "auto"; |
|
|
|
// Play immediately |
|
playAlarmBurst(kind); |
|
|
|
// Repeat every second |
|
alarmInterval = setInterval(() => { |
|
playAlarmBurst(kind); |
|
}, 1000); |
|
|
|
// Hard stop after configured duration |
|
const durationMs = Math.max(1, alarmDurationSeconds) * 1000; |
|
alarmStopTimeout = setTimeout(() => { |
|
stopAlarm(); |
|
}, durationMs); |
|
} |
|
|
|
// 🛑 Stop alarm (timeout or acknowledge) |
|
function stopAlarm() { |
|
if (!alarmActive) return; |
|
alarmActive = false; |
|
|
|
if (alarmInterval) { |
|
clearInterval(alarmInterval); |
|
alarmInterval = null; |
|
} |
|
if (alarmStopTimeout) { |
|
clearTimeout(alarmStopTimeout); |
|
alarmStopTimeout = null; |
|
} |
|
|
|
const ackBtn = document.getElementById('ack-btn'); |
|
const ackContainer = document.getElementById('ack-container'); |
|
|
|
ackBtn.disabled = true; |
|
ackContainer.style.opacity = "0"; |
|
ackContainer.style.pointerEvents = "none"; |
|
} |
|
|
|
// 🫰 User acknowledges alarm |
|
function acknowledgeAlarm() { |
|
stopAlarm(); |
|
} |
|
|
|
// 🔓 Unlock audio on first click |
|
document.getElementById("unlock-overlay").onclick = () => { |
|
soundEnabled = true; |
|
ensureAudioContext(); |
|
document.getElementById("unlock-overlay").style.display = "none"; |
|
}; |
|
|
|
// ▶ Preview the selected sound |
|
function previewSound() { |
|
if (!soundEnabled) { |
|
alert("Click anywhere on the page first to enable sound."); |
|
return; |
|
} |
|
const kind = document.getElementById("sound-select").value; |
|
playAlarmBurst(kind); |
|
} |
|
|
|
// 🎨 Font logic |
|
|
|
function applyFontChoice(choice) { |
|
const el = document.getElementById("countdown"); |
|
|
|
// reset some properties |
|
el.style.letterSpacing = ""; |
|
el.style.fontWeight = ""; |
|
|
|
if (choice === "digital") { |
|
el.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"; |
|
el.style.letterSpacing = "6px"; |
|
el.style.fontWeight = "600"; |
|
} else if (choice === "rounded") { |
|
el.style.fontFamily = "ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; |
|
el.style.fontWeight = "600"; |
|
} else if (choice === "system") { |
|
el.style.fontFamily = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; |
|
el.style.fontWeight = "700"; |
|
} else if (choice === "bold") { |
|
el.style.fontFamily = "'Arial Black', 'Segoe UI Black', Impact, system-ui, sans-serif"; |
|
el.style.fontWeight = "900"; |
|
} else if (choice === "serif") { |
|
el.style.fontFamily = "ui-serif, 'Georgia', 'Times New Roman', serif"; |
|
el.style.fontWeight = "700"; |
|
} |
|
|
|
try { |
|
localStorage.setItem("timerFontChoice", choice); |
|
} catch (e) { |
|
// ignore storage errors |
|
} |
|
} |
|
|
|
function initFontChoice() { |
|
const select = document.getElementById("font-select"); |
|
let saved = null; |
|
try { |
|
saved = localStorage.getItem("timerFontChoice"); |
|
} catch (e) { |
|
saved = null; |
|
} |
|
|
|
if (saved && [...select.options].some(o => o.value === saved)) { |
|
select.value = saved; |
|
} |
|
|
|
applyFontChoice(select.value); |
|
|
|
select.addEventListener("change", () => { |
|
applyFontChoice(select.value); |
|
}); |
|
} |
|
|
|
// 🎨 Color picker logic |
|
|
|
function applyColors(fill, outline, bg) { |
|
const countdown = document.getElementById("countdown"); |
|
|
|
if (fill) { |
|
countdown.style.color = fill; |
|
} |
|
if (outline) { |
|
countdown.style.textShadow = |
|
`0 0 5px ${outline}, 0 0 10px ${outline}, 0 0 15px ${outline}`; |
|
} |
|
if (bg) { |
|
document.body.style.backgroundColor = bg; |
|
} |
|
|
|
// persist |
|
try { |
|
localStorage.setItem("timerFillColor", fill); |
|
localStorage.setItem("timerOutlineColor", outline); |
|
localStorage.setItem("timerBgColor", bg); |
|
} catch (e) { |
|
// ignore |
|
} |
|
} |
|
|
|
function initColors() { |
|
const fillInput = document.getElementById("fill-color"); |
|
const outlineInput = document.getElementById("outline-color"); |
|
const bgInput = document.getElementById("bg-color"); |
|
|
|
let fill = fillInput.value; |
|
let outline = outlineInput.value; |
|
let bg = bgInput.value; |
|
|
|
try { |
|
const savedFill = localStorage.getItem("timerFillColor"); |
|
const savedOutline = localStorage.getItem("timerOutlineColor"); |
|
const savedBg = localStorage.getItem("timerBgColor"); |
|
if (savedFill) fill = savedFill; |
|
if (savedOutline) outline = savedOutline; |
|
if (savedBg) bg = savedBg; |
|
} catch (e) { |
|
// ignore |
|
} |
|
|
|
// sync inputs with effective values |
|
fillInput.value = fill; |
|
outlineInput.value = outline; |
|
bgInput.value = bg; |
|
|
|
applyColors(fill, outline, bg); |
|
|
|
function updateFromInputs() { |
|
applyColors(fillInput.value, outlineInput.value, bgInput.value); |
|
} |
|
|
|
fillInput.addEventListener("input", updateFromInputs); |
|
outlineInput.addEventListener("input", updateFromInputs); |
|
} |
|
|
|
// ⏱ Alarm duration config |
|
|
|
function applyAlarmDurationFromInput() { |
|
const input = document.getElementById("alarm-duration"); |
|
let v = parseInt(input.value, 10); |
|
|
|
if (isNaN(v) || v < 1) v = 1; |
|
if (v > 600) v = 600; // cap at 10 minutes just in case |
|
|
|
input.value = v; |
|
alarmDurationSeconds = v; |
|
|
|
try { |
|
localStorage.setItem("timerAlarmDurationSeconds", String(v)); |
|
} catch (e) { |
|
// ignore |
|
} |
|
} |
|
|
|
function initAlarmDuration() { |
|
const input = document.getElementById("alarm-duration"); |
|
let v = 30; |
|
try { |
|
const saved = localStorage.getItem("timerAlarmDurationSeconds"); |
|
if (saved !== null) { |
|
const parsed = parseInt(saved, 10); |
|
if (!isNaN(parsed)) { |
|
v = parsed; |
|
} |
|
} |
|
} catch (e) { |
|
// ignore |
|
} |
|
|
|
if (isNaN(v) || v < 1) v = 30; |
|
if (v > 600) v = 600; |
|
|
|
input.value = v; |
|
alarmDurationSeconds = v; |
|
|
|
input.addEventListener("change", applyAlarmDurationFromInput); |
|
input.addEventListener("blur", applyAlarmDurationFromInput); |
|
} |
|
|
|
// ⚙️ Config panel show/hide |
|
|
|
function showConfig() { |
|
const panel = document.getElementById("config-panel"); |
|
const hint = document.getElementById("config-hint"); |
|
panel.style.display = "flex"; |
|
configVisible = true; |
|
if (hint) hint.style.display = "none"; |
|
} |
|
|
|
function hideConfig() { |
|
const panel = document.getElementById("config-panel"); |
|
const hint = document.getElementById("config-hint"); |
|
panel.style.display = "none"; |
|
configVisible = false; |
|
if (hint) hint.style.display = "block"; |
|
} |
|
|
|
// Toggle config on 'c' key |
|
document.addEventListener("keydown", (e) => { |
|
if (e.key === "c" || e.key === "C") { |
|
if (configVisible) { |
|
hideConfig(); |
|
} else { |
|
showConfig(); |
|
} |
|
} |
|
}); |
|
|
|
async function mainloop() { |
|
try { |
|
const response = await fetch(backend_url + 'info/events/last_spawn/'); |
|
const data = await response.json(); |
|
|
|
const start = Date.now(); |
|
const target = start + data.next_spawn * 1000; |
|
|
|
function tick() { |
|
const now = Date.now(); |
|
const remaining = Math.max(0, Math.floor((target - now) / 1000)); |
|
|
|
const min = Math.floor(remaining / 60); |
|
const sec = Math.floor(remaining % 60); |
|
|
|
document.getElementById("countdown").textContent = |
|
`${pad(min)}:${pad(sec)}`; |
|
|
|
if (remaining > 0) { |
|
requestAnimationFrame(tick); |
|
} else { |
|
if (soundEnabled) { |
|
const kind = document.getElementById("sound-select").value; |
|
startAlarm(kind); |
|
} |
|
setTimeout(mainloop, 2000); |
|
} |
|
} |
|
|
|
tick(); |
|
|
|
} catch (err) { |
|
console.error("Error during timer loop:", err); |
|
setTimeout(mainloop, 5000); |
|
} |
|
} |
|
|
|
// init font, colors, duration & start |
|
initFontChoice(); |
|
initColors(); |
|
initAlarmDuration(); |
|
mainloop(); |
|
</script> |
|
|
|
</body> |
|
</html> |