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