initial commit

This commit is contained in:
2026-06-25 21:37:47 +00:00
commit 575632806d
+611
View File
@@ -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>