initial commit
This commit is contained in:
+611
@@ -0,0 +1,611 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Countdown Timers</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel: #151923;
|
||||||
|
--panel-elev: #1b2130;
|
||||||
|
--text: #e6e8f0;
|
||||||
|
--muted: #9aa3b2;
|
||||||
|
--accent: #6ea8fe;
|
||||||
|
--accent-strong: #4f8dff;
|
||||||
|
--danger: #ff5c7c;
|
||||||
|
--ring: rgba(110, 168, 254, 0.35);
|
||||||
|
--shadow: 0 10px 30px rgba(0,0,0,0.45);
|
||||||
|
--radius: 14px;
|
||||||
|
|
||||||
|
--tone-1: #6ea8fe; /* blue */
|
||||||
|
--tone-2: #27d796; /* green */
|
||||||
|
--tone-3: #ffb86b; /* orange */
|
||||||
|
--tone-4: #c084fc; /* purple */
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 80% -10%, #1a2030 0%, transparent 60%),
|
||||||
|
radial-gradient(900px 700px at -10% 110%, #171d29 0%, transparent 60%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap { max-width: 1140px; margin: 6vh auto 8vh; padding: 24px; }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-weight: 650; font-size: 22px;
|
||||||
|
}
|
||||||
|
.page-title .dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--accent); box-shadow: 0 0 14px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-elev) 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px;
|
||||||
|
outline: none;
|
||||||
|
transition: box-shadow 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
.card:focus-within, .card.active {
|
||||||
|
border-color: rgba(110,168,254,0.45);
|
||||||
|
box-shadow: 0 0 0 6px var(--ring), var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.timer-head .led {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
box-shadow: 0 0 12px currentColor;
|
||||||
|
}
|
||||||
|
.timer-head .name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 650; font-size: 16px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.timer-head input[type="text"] {
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.timer-head input[type="text"]:focus {
|
||||||
|
border-color: rgba(110,168,254,0.55);
|
||||||
|
box-shadow: 0 0 0 6px var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
user-select: none;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(36px, 6.2vw, 64px);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
padding: 22px 16px;
|
||||||
|
border-radius: calc(var(--radius) - 4px);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.display.pulse { animation: pulse 700ms ease-in-out 3; }
|
||||||
|
.display.alarming { animation: pulse 900ms ease-in-out infinite; }
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 var(--ring); }
|
||||||
|
100% { box-shadow: 0 0 0 26px rgba(110, 168, 254, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: linear-gradient(180deg, #1a2030 0%, #171d29 100%);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px; border-radius: 12px;
|
||||||
|
font-size: 14px; font-weight: 650; cursor: pointer;
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease, background 160ms ease, box-shadow 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.16); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.btn.accent {
|
||||||
|
background: linear-gradient(180deg, #2b3a55 0%, #22314a 100%);
|
||||||
|
border-color: rgba(110,168,254,0.45);
|
||||||
|
color: #deebff;
|
||||||
|
box-shadow: 0 8px 24px rgba(110,168,254,0.18), inset 0 0 0 1px rgba(110,168,254,0.25);
|
||||||
|
}
|
||||||
|
.btn.danger {
|
||||||
|
background: linear-gradient(180deg, #3b2130 0%, #2c1a25 100%);
|
||||||
|
border-color: rgba(255,92,124,0.45);
|
||||||
|
color: #ffdee6;
|
||||||
|
box-shadow: 0 8px 24px rgba(255,92,124,0.18), inset 0 0 0 1px rgba(255,92,124,0.25);
|
||||||
|
}
|
||||||
|
.btn.ghost { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
.preset { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.preset .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
|
||||||
|
.time-input { display: flex; gap: 10px; align-items: center; width: 100%; }
|
||||||
|
.time-input input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 12px; padding: 10px 12px; font-size: 14px; outline: none;
|
||||||
|
}
|
||||||
|
.time-input input:focus {
|
||||||
|
border-color: rgba(110,168,254,0.55);
|
||||||
|
box-shadow: 0 0 0 6px var(--ring);
|
||||||
|
}
|
||||||
|
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
margin-top: 16px; color: var(--muted);
|
||||||
|
font-size: 12px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
/* Volume slider styling */
|
||||||
|
.vol-wrap { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.vol-label { font-size: 13px; color: var(--text); opacity: 0.9; }
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
height: 4px; width: 200px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid rgba(255,255,255,0.25);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid rgba(255,255,255,0.25);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.vol-pct { min-width: 3ch; text-align: right; color: var(--text); }
|
||||||
|
.kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
padding: 2px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1 class="page-title"><span class="dot"></span> Countdown Timers (4)</h1>
|
||||||
|
|
||||||
|
<div class="grid" id="grid">
|
||||||
|
<!-- Cards injected by JS -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-footer">
|
||||||
|
<div>
|
||||||
|
Alarms loop until silenced. Distinct tones per timer.
|
||||||
|
Shortcuts (last clicked timer): <span class="kbd">Enter</span> start • <span class="kbd">Space</span> pause/resume • <span class="kbd">R</span> reset • <span class="kbd">S</span> silence
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="vol-wrap">
|
||||||
|
<label for="volume" class="vol-label">🔊 Volume</label>
|
||||||
|
<input id="volume" type="range" min="0" max="150" step="1" value="100" aria-label="Alarm volume (0 to 150 percent)">
|
||||||
|
<span id="volPct" class="vol-pct">100%</span>
|
||||||
|
</div>
|
||||||
|
<button id="stopAll" class="btn danger">⏹ Stop All Alarms</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// ---------- Utilities ----------
|
||||||
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||||
|
|
||||||
|
// ---------- Audio (shared) ----------
|
||||||
|
let audioCtx = null;
|
||||||
|
let masterGain = null;
|
||||||
|
let volumePct = 100; // 0..150 (%), persisted in localStorage
|
||||||
|
const VOL_KEY = 'ctimers.volume';
|
||||||
|
|
||||||
|
function initAudio() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (Ctx) {
|
||||||
|
audioCtx = new Ctx();
|
||||||
|
masterGain = audioCtx.createGain();
|
||||||
|
// Apply current volume when context is created
|
||||||
|
masterGain.gain.value = volumePct / 100;
|
||||||
|
masterGain.connect(audioCtx.destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolumePercent(pct) {
|
||||||
|
volumePct = clamp(Number(pct) || 0, 0, 150);
|
||||||
|
if (masterGain) {
|
||||||
|
masterGain.gain.value = volumePct / 100;
|
||||||
|
}
|
||||||
|
volPct.textContent = `${volumePct}%`;
|
||||||
|
localStorage.setItem(VOL_KEY, String(volumePct));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHMS(sec) {
|
||||||
|
sec = Math.max(0, Math.floor(sec));
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||||
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(input) {
|
||||||
|
if (!input) return 0;
|
||||||
|
const str = String(input).trim();
|
||||||
|
if (!str) return 0;
|
||||||
|
const parts = str.split(':').map(p => p.trim());
|
||||||
|
if (parts.some(p => p === '' || isNaN(p))) return 0;
|
||||||
|
let h=0,m=0,s=0;
|
||||||
|
if (parts.length === 1) { s = Number(parts[0]); }
|
||||||
|
else if (parts.length === 2) { m = Number(parts[0]); s = Number(parts[1]); }
|
||||||
|
else if (parts.length === 3) { h = Number(parts[0]); m = Number(parts[1]); s = Number(parts[2]); }
|
||||||
|
else return 0;
|
||||||
|
if (![h,m,s].every(n => Number.isFinite(n) && n >= 0)) return 0;
|
||||||
|
return h*3600 + m*60 + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct beep sequences for 4 timers
|
||||||
|
const toneSets = [
|
||||||
|
[880, 830, 784], // bright blue-ish
|
||||||
|
[660, 622, 587], // mid green-ish
|
||||||
|
[523, 494, 466], // warm orange-ish
|
||||||
|
[988, 932, 880], // bright purple-ish
|
||||||
|
];
|
||||||
|
|
||||||
|
// One-shot tri-beep; overall loudness is controlled by masterGain (volume slider)
|
||||||
|
function beepPattern(timerIndex = 0) {
|
||||||
|
initAudio();
|
||||||
|
if (!audioCtx || !masterGain) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
const freqs = toneSets[timerIndex % toneSets.length];
|
||||||
|
const sequence = [0, 0.25, 0.5]; // three beeps 250ms apart
|
||||||
|
|
||||||
|
sequence.forEach((offset, i) => {
|
||||||
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
const peak = 0.55; // internal envelope peak (masterGain applies user volume)
|
||||||
|
const freq = freqs[i % freqs.length];
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
gain.gain.setValueAtTime(0.0001, now + offset);
|
||||||
|
gain.gain.linearRampToValueAtTime(peak, now + offset + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + offset + 0.22);
|
||||||
|
osc.connect(gain).connect(masterGain);
|
||||||
|
osc.start(now + offset);
|
||||||
|
osc.stop(now + offset + 0.26);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Haptics where supported
|
||||||
|
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
const stopAllBtn = document.getElementById('stopAll');
|
||||||
|
const volumeSlider = document.getElementById('volume');
|
||||||
|
const volPct = document.getElementById('volPct');
|
||||||
|
|
||||||
|
// Load saved volume (if any)
|
||||||
|
const saved = Number(localStorage.getItem(VOL_KEY));
|
||||||
|
if (Number.isFinite(saved)) {
|
||||||
|
volumeSlider.value = String(clamp(saved, 0, 150));
|
||||||
|
volumePct = clamp(saved, 0, 150);
|
||||||
|
} else {
|
||||||
|
volumeSlider.value = '100';
|
||||||
|
}
|
||||||
|
volPct.textContent = `${volumePct}%`;
|
||||||
|
|
||||||
|
// Defer touching AudioContext until a user gesture starts audio;
|
||||||
|
// still update master gain immediately if it already exists.
|
||||||
|
volumeSlider.addEventListener('input', (e) => {
|
||||||
|
setVolumePercent(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- UI: Create a timer card ----------
|
||||||
|
function createTimerCard(idx, colorCSS) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.tabIndex = 0;
|
||||||
|
card.dataset.index = idx;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="timer-head">
|
||||||
|
<span class="led" style="color:${colorCSS}; background:${colorCSS}"></span>
|
||||||
|
<div class="name">
|
||||||
|
<input type="text" class="label-input" placeholder="Timer ${idx+1}" aria-label="Timer label">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="display" aria-live="polite">00:00</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn preset" data-min="1"><span class="dot"></span>1 min</button>
|
||||||
|
<button class="btn preset" data-min="5"><span class="dot"></span>5 min</button>
|
||||||
|
<button class="btn preset" data-min="10"><span class="dot"></span>10 min</button>
|
||||||
|
<button class="btn preset" data-min="30"><span class="dot"></span>30 min</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row time-input">
|
||||||
|
<input type="text" class="manual" inputmode="numeric" placeholder="SS, MM:SS, or HH:MM:SS" aria-label="Manual time entry" />
|
||||||
|
<button class="btn ghost apply">Set</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Examples: <strong>45</strong> (45 sec), <strong>3:00</strong>, <strong>1:15:00</strong></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn accent start">Start</button>
|
||||||
|
<button class="btn ghost pause" disabled>Pause</button>
|
||||||
|
<button class="btn danger reset" disabled>Reset</button>
|
||||||
|
<button class="btn danger silence" disabled>Silence</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.appendChild(card);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Timer logic per card ----------
|
||||||
|
function attachTimerBehavior(card, index) {
|
||||||
|
const display = card.querySelector('.display');
|
||||||
|
const startBtn = card.querySelector('.start');
|
||||||
|
const pauseBtn = card.querySelector('.pause');
|
||||||
|
const resetBtn = card.querySelector('.reset');
|
||||||
|
const silenceBtn = card.querySelector('.silence');
|
||||||
|
const manual = card.querySelector('.manual');
|
||||||
|
const applyBtn = card.querySelector('.apply');
|
||||||
|
const labelInput = card.querySelector('.label-input');
|
||||||
|
const presetButtons = Array.from(card.querySelectorAll('.preset'));
|
||||||
|
|
||||||
|
let totalSeconds = 0;
|
||||||
|
let remaining = 0;
|
||||||
|
let timerId = null;
|
||||||
|
let running = false;
|
||||||
|
let paused = false;
|
||||||
|
let endTimestamp = null;
|
||||||
|
|
||||||
|
// Alarm loop state
|
||||||
|
let alarming = false;
|
||||||
|
let alarmInterval = null;
|
||||||
|
|
||||||
|
function setTitleForAll() {
|
||||||
|
// Priority 1: any alarming timer
|
||||||
|
let alarmTarget = timers.find(t => t.alarming);
|
||||||
|
if (alarmTarget) {
|
||||||
|
const base = 'Countdown Timers';
|
||||||
|
const label = alarmTarget.labelInput.value.trim() || `Timer ${alarmTarget.index+1}`;
|
||||||
|
document.title = `⏰ ${label} — ${base}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: nearest finishing running timer
|
||||||
|
let best = null;
|
||||||
|
timers.forEach(t => {
|
||||||
|
if (t.running && !t.paused) {
|
||||||
|
if (best === null || t.remaining < best.remaining) best = t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const base = 'Countdown Timers';
|
||||||
|
if (!best) { document.title = base; return; }
|
||||||
|
const label = best.labelInput.value.trim() || `Timer ${best.index+1}`;
|
||||||
|
document.title = `${formatHMS(best.remaining)} — ${label} • ${base}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplay(sec, pulse=false) {
|
||||||
|
display.textContent = formatHMS(sec);
|
||||||
|
if (pulse) {
|
||||||
|
display.classList.remove('pulse'); void display.offsetWidth; display.classList.add('pulse');
|
||||||
|
}
|
||||||
|
setTitleForAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableControls(state) {
|
||||||
|
if (state === 'idle') {
|
||||||
|
startBtn.disabled = false; pauseBtn.disabled = true; resetBtn.disabled = true;
|
||||||
|
} else if (state === 'running') {
|
||||||
|
startBtn.disabled = true; pauseBtn.disabled = false; resetBtn.disabled = false; pauseBtn.textContent = 'Pause';
|
||||||
|
} else if (state === 'paused') {
|
||||||
|
startBtn.disabled = true; pauseBtn.disabled = false; resetBtn.disabled = false; pauseBtn.textContent = 'Resume';
|
||||||
|
}
|
||||||
|
// Silence button is only for alarming state
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAlarmLoop() {
|
||||||
|
if (alarmInterval) clearInterval(alarmInterval);
|
||||||
|
alarming = true;
|
||||||
|
display.classList.add('alarming');
|
||||||
|
silenceBtn.disabled = false;
|
||||||
|
// Immediate beep, then loop every ~1.2s
|
||||||
|
beepPattern(index);
|
||||||
|
alarmInterval = setInterval(() => beepPattern(index), 1200);
|
||||||
|
setTitleForAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAlarmLoop() {
|
||||||
|
if (alarmInterval) clearInterval(alarmInterval);
|
||||||
|
alarmInterval = null;
|
||||||
|
alarming = false;
|
||||||
|
display.classList.remove('alarming');
|
||||||
|
silenceBtn.disabled = true;
|
||||||
|
setTitleForAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const msLeft = endTimestamp - Date.now();
|
||||||
|
remaining = Math.ceil(msLeft / 1000);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(timerId); timerId = null;
|
||||||
|
running = false; paused = false; remaining = 0;
|
||||||
|
updateDisplay(0, true);
|
||||||
|
enableControls('idle');
|
||||||
|
// Start looping alarm until silenced
|
||||||
|
startAlarmLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateDisplay(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
// Starting should silence any ongoing alarm for this timer
|
||||||
|
stopAlarmLoop();
|
||||||
|
|
||||||
|
if (running && !paused) return;
|
||||||
|
if (remaining <= 0) remaining = totalSeconds;
|
||||||
|
if (remaining <= 0) return;
|
||||||
|
initAudio();
|
||||||
|
running = true; paused = false;
|
||||||
|
endTimestamp = Date.now() + remaining * 1000;
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
timerId = setInterval(tick, 100);
|
||||||
|
enableControls('running');
|
||||||
|
card.classList.add('active');
|
||||||
|
lastActive = index;
|
||||||
|
updateDisplay(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (!running) return;
|
||||||
|
if (!paused) {
|
||||||
|
paused = true;
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
const msLeft = endTimestamp - Date.now();
|
||||||
|
remaining = Math.max(0, Math.ceil(msLeft/1000));
|
||||||
|
enableControls('paused');
|
||||||
|
} else {
|
||||||
|
paused = false;
|
||||||
|
endTimestamp = Date.now() + remaining * 1000;
|
||||||
|
timerId = setInterval(tick, 100);
|
||||||
|
enableControls('running');
|
||||||
|
}
|
||||||
|
setTitleForAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
running = false; paused = false; remaining = 0;
|
||||||
|
stopAlarmLoop();
|
||||||
|
updateDisplay(totalSeconds || 0);
|
||||||
|
enableControls('idle');
|
||||||
|
setTitleForAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFromSeconds(sec) {
|
||||||
|
sec = Math.max(0, Math.min(sec, 24*3600));
|
||||||
|
totalSeconds = sec;
|
||||||
|
remaining = sec;
|
||||||
|
stopAlarmLoop();
|
||||||
|
updateDisplay(remaining);
|
||||||
|
enableControls('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up
|
||||||
|
presetButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => setFromSeconds(Number(btn.dataset.min) * 60));
|
||||||
|
});
|
||||||
|
applyBtn.addEventListener('click', () => setFromSeconds(parseTime(manual.value)));
|
||||||
|
manual.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); applyBtn.click(); }});
|
||||||
|
startBtn.addEventListener('click', start);
|
||||||
|
pauseBtn.addEventListener('click', pause);
|
||||||
|
resetBtn.addEventListener('click', reset);
|
||||||
|
silenceBtn.addEventListener('click', stopAlarmLoop);
|
||||||
|
|
||||||
|
// Clicking the card focuses this timer for keyboard shortcuts
|
||||||
|
card.addEventListener('mousedown', () => {
|
||||||
|
lastActive = index;
|
||||||
|
timers.forEach(t => t.card.classList.remove('active'));
|
||||||
|
card.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize with 5 minutes default
|
||||||
|
setFromSeconds(300);
|
||||||
|
|
||||||
|
// Expose public parts for global actions/title/keyboard
|
||||||
|
return {
|
||||||
|
index, card, start, pause, reset, setFromSeconds, stopAlarmLoop,
|
||||||
|
get running(){ return running; },
|
||||||
|
get paused(){ return paused; },
|
||||||
|
get remaining(){ return remaining; },
|
||||||
|
get alarming(){ return alarming; },
|
||||||
|
get labelInput(){ return labelInput; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Build 4 timers ----------
|
||||||
|
const timers = [];
|
||||||
|
for (let i=0; i<4; i++) {
|
||||||
|
const card = createTimerCard(i, `var(--tone-${i+1})`);
|
||||||
|
timers.push(attachTimerBehavior(card, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track last active timer for keyboard shortcuts
|
||||||
|
let lastActive = 0;
|
||||||
|
timers[0].card.classList.add('active');
|
||||||
|
|
||||||
|
// Keyboard shortcuts routed to the last active timer (unless typing)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const isTyping = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
|
||||||
|
if (isTyping) return;
|
||||||
|
|
||||||
|
const T = timers[lastActive];
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
T.start();
|
||||||
|
} else if (e.key === ' ' || e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
T.pause();
|
||||||
|
} else if (e.key.toLowerCase() === 'r') {
|
||||||
|
T.reset();
|
||||||
|
} else if (e.key.toLowerCase() === 's') {
|
||||||
|
T.stopAlarmLoop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop all alarms button
|
||||||
|
stopAllBtn.addEventListener('click', () => {
|
||||||
|
timers.forEach(t => t.stopAlarmLoop());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply initial volume (will affect masterGain after first initAudio())
|
||||||
|
setVolumePercent(volumePct);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user