import { store } from '../store';
import { createSecondTicker, nextCeilBoundary } from '../secondTick';
import { formatHMS } from '../../shared/time';
import { t } from '../i18n';
import type { CountdownActiveState } from '../../shared/types';
/**
* 通用倒计时面板:
* - idle:HH/MM/SS 输入框 + [开始]
* - running:圆环 + 剩余时间 + [暂停][重置]
* - paused:圆环 + 剩余时间 + [继续][重置]
* - fired(瞬时):大字"时间到" + [完成](8 秒无操作自动返回)
*
* 输入框变化时(idle):本地更新 lastDurationMs + 调 setDuration IPC。
* 注意:fired 状态由 phaseChange(null) 维持;panel 内部 setTimeout 8 秒后自动清除。
*/
export function renderCountdownPanel(root: HTMLElement): () => void {
root.innerHTML = `
`;
const inputHh = root.querySelector('#cd-input-hh')!;
const inputMm = root.querySelector('#cd-input-mm')!;
const inputSs = root.querySelector('#cd-input-ss')!;
const inputRow = root.querySelector('#cd-input-row')!;
const time = root.querySelector('#cd-time')!;
const sub = root.querySelector('#cd-sub')!;
const ring = root.querySelector('#cd-ring-fg')!;
const stateEl = root.querySelector('#cd-state')!;
const timerEl = root.querySelector('#cd-timer')!;
const actions = root.querySelector('#cd-actions')!;
const btnStart = root.querySelector('#cd-start')!;
const btnPause = root.querySelector('#cd-pause')!;
const btnResume = root.querySelector('#cd-resume')!;
const btnReset = root.querySelector('#cd-reset')!;
function readDurationMs(): number {
const h = clamp(inputHh.value, 0, 99);
const m = clamp(inputMm.value, 0, 59);
const s = clamp(inputSs.value, 0, 59);
return ((h * 3600) + (m * 60) + s) * 1000;
}
function clamp(raw: string, lo: number, hi: number): number {
const n = Number.parseInt(raw || '0', 10);
if (!Number.isFinite(n)) return lo;
return Math.min(hi, Math.max(lo, n));
}
function syncInputsFromMs(ms: number): void {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
inputHh.value = String(h);
inputMm.value = String(m);
inputSs.value = String(s);
}
function formatHM(totalSec: number): string {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
if (h > 0) return t('countdown.fmtHoursMinutes', String(h), String(m));
if (m > 0) return t('countdown.fmtMinutes', String(m));
if (totalSec > 0) return t('countdown.fmtSeconds', String(totalSec));
return t('countdown.inputEmpty');
}
// fired 是 UI-only 的瞬时态:主进程广播 phaseChanged(null) 之后
// active 已为 null,靠这个标志把 idle 和 fired 区分开。
let fired = false;
let firedTimer: number | null = null;
function clearFiredTimer(): void {
if (firedTimer !== null) {
window.clearTimeout(firedTimer);
firedTimer = null;
}
}
function exitFired(): void {
clearFiredTimer();
fired = false;
draw();
// exitFired 后 btnReset 被 hide(draw 的 idle 分支),焦点若停留在它上面,
// 浏览器会把焦点挪回 body 而不是 idle 路径里"应该接住"的 btnStart。
// 显式 focus 让键盘/屏读用户后续 Enter 能直接启动,不必先 Tab 找按钮。
btnStart.focus({ preventScroll: true });
}
function enterFired(): void {
fired = true;
clearFiredTimer();
// 8 秒无操作自动回到 idle;按钮在本轮 draw 后获得焦点。
firedTimer = window.setTimeout(() => {
firedTimer = null;
exitFired();
}, 8000);
// queueMicrotask 主要是为绕开 sync 路径里 focus 切换被吞的问题;
// 实际上 fired 只在 enterFired 同步路径里被设成 true,下面的 focus
// 直接同步调用即可 —— 微任务那一层判断 fired 永远为 true,没意义。
btnReset.focus({ preventScroll: true });
}
// 显隐一律走内联 style.display,不要用 [hidden] 属性:
// 按钮会被 global.css 的 `.btn[hidden] { display: none }` 锁死,
// timerEl / inputRow 则反过来被自身的 display:flex 盖过 UA 的 [hidden]。
function setVisible(el: HTMLElement, v: boolean): void {
el.style.display = v ? '' : 'none';
}
function draw(): void {
const { active, lastDurationMs } = store.countdown.get();
if (!active) {
syncInputsFromMs(lastDurationMs);
const total = Math.ceil(lastDurationMs / 1000);
time.textContent = formatHMS(fired ? 0 : total);
ring.setAttribute('stroke-dasharray', '0 100');
ring.classList.add('idle');
// 没有 active 就不该呼吸。running → fired 时若不显式清零,
// 上一轮留下的 '1' 会让"时间到"表盘一直脉动。
timerEl.style.setProperty('--ring-pulse', '0');
timerEl.dataset.pulse = '0';
if (fired) {
sub.textContent = t('countdown.timeUp');
stateEl.textContent = t('countdown.timeUp');
stateEl.dataset.state = 'fired';
actions.dataset.state = 'fired';
setVisible(btnStart, false);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, true);
btnReset.textContent = t('countdown.doneBtn');
btnReset.classList.remove('ghost');
btnReset.classList.add('primary');
btnReset.disabled = false;
setVisible(timerEl, true); // fired:表盘显示"时间到"
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
sub.textContent = t('countdown.setN', formatHM(total));
stateEl.textContent = t('countdown.stateIdle');
stateEl.dataset.state = 'idle';
actions.dataset.state = 'idle';
setVisible(btnStart, true);
setVisible(btnPause, false);
setVisible(btnResume, false);
setVisible(btnReset, false);
setVisible(timerEl, false); // idle:表盘整组隐藏(避免与输入行重复显示 HH:MM:SS)
inputRow.style.display = '';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = false; });
btnStart.disabled = lastDurationMs <= 0;
return;
}
btnReset.textContent = t('countdown.resetBtn');
btnReset.classList.remove('primary');
btnReset.classList.add('ghost');
const remain = active.paused
? active.remainingMs
: Math.max(0, active.endsAt - Date.now());
const totalSec = Math.ceil(remain / 1000);
time.textContent = formatHMS(totalSec);
sub.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.textContent = active.paused ? t('countdown.statePaused') : t('countdown.stateRunning');
stateEl.dataset.state = active.paused ? 'paused' : 'running';
const ratio = Math.max(0, Math.min(1, remain / active.durationMs));
ring.setAttribute('stroke-dasharray', `${ratio * 100} 100`);
ring.classList.remove('idle');
// 暂停时不呼吸;运行中呼吸
timerEl.style.setProperty('--ring-pulse', active.paused ? '0' : '1');
timerEl.dataset.pulse = active.paused ? '0' : '1';
actions.dataset.state = active.paused ? 'paused' : 'running';
setVisible(btnStart, false);
setVisible(btnPause, !active.paused);
setVisible(btnResume, active.paused);
setVisible(btnReset, true);
setVisible(timerEl, true); // running/paused:表盘显示
inputRow.style.display = 'none';
[inputHh, inputMm, inputSs].forEach(i => { i.disabled = true; });
// aria 同步:让屏读器知道当前剩余时间(每秒 240ms 节流,无屏读噪音)
timerEl.setAttribute('aria-label',
active.paused ? t('countdown.ariaPausedFmt', formatHM(totalSec)) : t('countdown.ariaRunningFmt', formatHM(totalSec)));
}
// 输入框变化:本地更新 + IPC 持久化 lastDurationMs
// 附带 sanitize:type="text" 允许任意字符,过滤掉非数字;超过 maxlength 自动截断。
function sanitizeInput(el: HTMLInputElement): void {
const cleaned = el.value.replace(/\D/g, '').slice(0, el.maxLength || 2);
if (cleaned !== el.value) el.value = cleaned;
}
function onInputChange(): void {
if (store.countdown.get().active) return; // 运行中不允许改
[inputHh, inputMm, inputSs].forEach(sanitizeInput);
const ms = readDurationMs();
store.countdown.set({ active: null, lastDurationMs: ms });
btnStart.disabled = ms <= 0;
// ms<=0 时不发 IPC:主进程会显式拒绝(避免「上次时长 = 0」污染持久化),
// 未捕获的 promise 拒绝会被 main.ts 的 unhandledrejection handler 弹成 toast,
// 把无效输入静默吃掉更友好。start 按钮已在上一行禁用,无法启动。
if (ms > 0) void window.api.countdown.setDuration(ms);
}
// 鼠标滚轮调整数值:wheel-up +1,wheel-down -1;按住 Shift 时步进为 5。
// 运行中(active)不响应,避免误触;fired/disabled 状态也跳过 —— 否则 disabled
// 输入框仍会触发 wheel 事件并被 preventDefault 吞掉页面滚动,鼠标停在它上面
// 就划不动页面(fired 时三个输入框都是 disabled=true)。
function onWheelAdjust(e: WheelEvent): void {
const el = e.currentTarget as HTMLInputElement;
if (el.disabled) return;
if (store.countdown.get().active) return;
e.preventDefault();
const max = el === inputHh ? 99 : 59;
const cur = clamp(el.value, 0, max);
const dir = e.deltaY < 0 ? 1 : -1;
const step = e.shiftKey ? 5 : 1;
const next = Math.max(0, Math.min(max, cur + dir * step));
if (next === cur) return;
el.value = String(next);
onInputChange();
}
btnStart.onclick = () => {
const ms = readDurationMs();
if (ms <= 0) return;
void window.api.countdown.start(ms);
};
btnPause.onclick = () => void window.api.countdown.pause();
btnResume.onclick = () => void window.api.countdown.resume();
btnReset.onclick = () => {
if (fired) {
// fired 状态下按"完成"= 用户确认听到了提醒,主进程必须立刻把所有还在跑
// 的提醒路径停掉。当前唯一通道是系统通知,cancelAll 已是 no-op;
// 保留 IPC 调用是给未来恢复声音/全屏闪时不用再改面板。
void window.api.notifier.cancel();
exitFired();
return;
}
void window.api.countdown.reset();
};
// active 由非 null 变 null 有两种可能:自然归零(fired) 或 用户 reset。
// 区分依据:自然归零时 endsAt 已经到点(<= now);reset 时 endsAt 还在未来。
let prevActive: CountdownActiveState | null = null;
const unsubStore = store.countdown.subscribe(({ active }) => {
if (prevActive && !active
&& !prevActive.paused
&& prevActive.endsAt <= Date.now()) {
enterFired();
}
prevActive = active;
draw();
ticker.restart();
});
// 持续刷新(圆环 + 大字):睡到读数下一次改变,而不是 rAF 60Hz + 240ms 闸门。
// idle(显示上次设定时长)与 paused 下读数不会自己变,nextAt 返回 null,不装定时器。
const ticker = createSecondTicker(draw, () => {
const { active } = store.countdown.get();
return !active || active.paused ? null : nextCeilBoundary(active.endsAt);
});
draw();
// 输入框事件 listener:cleanup 时一起移除
const inputListeners: Array<[HTMLInputElement, typeof onInputChange]> = [
[inputHh, onInputChange], [inputMm, onInputChange], [inputSs, onInputChange]
];
inputListeners.forEach(([el, fn]) => el.addEventListener('input', fn));
// 滚轮调整:HH/MM/SS 都注册 wheel 监听,按目标元素的不同 max 限制(HH 0-99、MM/SS 0-59)
const wheelListener: typeof onWheelAdjust = onWheelAdjust;
[inputHh, inputMm, inputSs].forEach(el => {
el.addEventListener('wheel', wheelListener, { passive: false });
});
return () => {
ticker.stop();
clearFiredTimer();
inputListeners.forEach(([el, fn]) => el.removeEventListener('input', fn));
[inputHh, inputMm, inputSs].forEach(el => el.removeEventListener('wheel', wheelListener));
unsubStore();
};
}