Simple watchface with second-hand wobble animation, 5-min idle auto-show, W key toggle, light/dark theme colors, backdrop blur from theme settings. Grace period prevents instant dismiss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
170 lines
4.6 KiB
JavaScript
170 lines
4.6 KiB
JavaScript
// ── Watch plugin — analog clock overlay ──
|
|
|
|
const $ = id => document.getElementById(id);
|
|
|
|
const LAYER_MAP = {
|
|
digits: 'watch-layer-digits',
|
|
hour: 'watch-layer-hour',
|
|
minute: 'watch-layer-minute',
|
|
second: 'watch-second-wobble',
|
|
};
|
|
|
|
let visible = false;
|
|
let tickTimer = null;
|
|
let idleTimer = null;
|
|
let lastActivity = Date.now();
|
|
let showedAt = 0;
|
|
let wobbleKick = null, wobbleRebound = null;
|
|
let svgCache = {};
|
|
let loaded = false;
|
|
|
|
const IDLE_MINUTES = 5;
|
|
|
|
// ── SVG loading ──
|
|
|
|
async function loadSvg(layer) {
|
|
if (svgCache[layer]) return svgCache[layer];
|
|
try {
|
|
const res = await fetch(`plugins/watch/faces/simple/${layer}.svg`);
|
|
if (!res.ok) return '';
|
|
let svg = await res.text();
|
|
svg = svg.replace(/<\?xml[\s\S]*?\?>/gi, '').replace(/<!DOCTYPE[\s\S]*?>/gi, '').trim();
|
|
svgCache[layer] = svg;
|
|
return svg;
|
|
} catch { return ''; }
|
|
}
|
|
|
|
async function loadFace() {
|
|
if (loaded) return;
|
|
for (const [layer, elId] of Object.entries(LAYER_MAP)) {
|
|
const el = $(elId);
|
|
if (el) el.innerHTML = await loadSvg(layer);
|
|
}
|
|
loaded = true;
|
|
}
|
|
|
|
// ── Clock logic ──
|
|
|
|
function tick() {
|
|
const now = new Date();
|
|
const h12 = now.getHours() % 12;
|
|
const m = now.getMinutes();
|
|
const s = now.getSeconds();
|
|
|
|
const hourEl = $('watch-layer-hour');
|
|
const minEl = $('watch-layer-minute');
|
|
const secEl = $('watch-layer-second');
|
|
|
|
if (hourEl) hourEl.style.transform = `rotate(${(((h12 + m / 60) / 12) * 360).toFixed(3)}deg)`;
|
|
if (minEl) minEl.style.transform = `rotate(${(((m + s / 60) / 60) * 360).toFixed(3)}deg)`;
|
|
if (secEl) secEl.style.transform = `rotate(${((s / 60) * 360).toFixed(3)}deg)`;
|
|
|
|
triggerWobble();
|
|
}
|
|
|
|
function triggerWobble() {
|
|
const wobEl = $('watch-second-wobble');
|
|
if (!wobEl) return;
|
|
clearTimeout(wobbleKick);
|
|
clearTimeout(wobbleRebound);
|
|
wobEl.style.transform = 'rotate(0deg)';
|
|
requestAnimationFrame(() => {
|
|
wobEl.style.transform = 'rotate(0.78deg)';
|
|
wobbleKick = setTimeout(() => { wobEl.style.transform = 'rotate(-0.25deg)'; }, 95);
|
|
wobbleRebound = setTimeout(() => { wobEl.style.transform = 'rotate(0deg)'; }, 205);
|
|
});
|
|
}
|
|
|
|
function startTick() {
|
|
if (tickTimer) return;
|
|
tick();
|
|
tickTimer = setInterval(tick, 1000);
|
|
}
|
|
|
|
function stopTick() {
|
|
if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
|
|
clearTimeout(wobbleKick);
|
|
clearTimeout(wobbleRebound);
|
|
}
|
|
|
|
// ── Show / hide ──
|
|
|
|
async function show() {
|
|
if (visible) return;
|
|
visible = true;
|
|
const overlay = $('watch-overlay');
|
|
if (!overlay) return;
|
|
showedAt = Date.now();
|
|
await loadFace();
|
|
overlay.classList.remove('hidden');
|
|
startTick();
|
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
|
}
|
|
|
|
function hide() {
|
|
if (!visible) return;
|
|
visible = false;
|
|
const overlay = $('watch-overlay');
|
|
if (!overlay) return;
|
|
overlay.classList.remove('visible');
|
|
stopTick();
|
|
setTimeout(() => { if (!visible) overlay.classList.add('hidden'); }, 250);
|
|
}
|
|
|
|
function toggle() { visible ? hide() : show(); }
|
|
|
|
// ── Idle auto-show ──
|
|
|
|
function resetIdleTimer() {
|
|
clearTimeout(idleTimer);
|
|
if (!IDLE_MINUTES) return;
|
|
idleTimer = setTimeout(() => {
|
|
if (visible) return;
|
|
if (document.visibilityState !== 'visible') return;
|
|
if (document.querySelector('.overlay.open')) return;
|
|
show();
|
|
}, IDLE_MINUTES * 60000);
|
|
}
|
|
|
|
function onActivity() {
|
|
const now = Date.now();
|
|
if (now - lastActivity < 300) return;
|
|
lastActivity = now;
|
|
if (visible && now - showedAt > 500) hide();
|
|
resetIdleTimer();
|
|
}
|
|
|
|
// ── Init ──
|
|
|
|
function init() {
|
|
const overlay = $('watch-overlay');
|
|
if (!overlay) return;
|
|
|
|
overlay.addEventListener('click', () => { hide(); onActivity(); });
|
|
|
|
window.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && visible) {
|
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
|
hide(); onActivity();
|
|
}
|
|
}, { capture: true });
|
|
|
|
for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) {
|
|
window.addEventListener(ev, onActivity, { passive: true });
|
|
}
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') onActivity();
|
|
});
|
|
|
|
resetIdleTimer();
|
|
window.hyprcrd?.registerPlugin({ name: 'watch', version: '1.0.0' });
|
|
}
|
|
|
|
window._watchToggle = toggle;
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|