Skip to content

Instantly share code, notes, and snippets.

@jalberto
Created August 11, 2026 22:50
Show Gist options
  • Select an option

  • Save jalberto/0bf514ea54f3d7aa9d534c246d43bb07 to your computer and use it in GitHub Desktop.

Select an option

Save jalberto/0bf514ea54f3d7aa9d534c246d43bb07 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tap Counter</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; font-family: -apple-system, system-ui, sans-serif;
background: #111; color: #eee; display: flex; flex-direction: column;
align-items: center; min-height: 100vh; padding: 16px;
}
h1 { font-size: 1.1rem; color: #f5a623; margin: 8px 0; }
.video-wrap { position: relative; width: 100%; max-width: 480px; }
video, canvas.overlay { width: 100%; border-radius: 8px; display: block; }
canvas.overlay {
position: absolute; top: 0; left: 0; pointer-events: none;
}
#roiBox {
position: absolute; border: 2px dashed #f5a623; cursor: move;
}
.counter {
font-size: 3.5rem; font-weight: 700; margin: 12px 0; color: #4caf50;
}
.controls { width: 100%; max-width: 480px; margin-top: 8px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
label { flex: 0 0 110px; font-size: 0.85rem; color: #aaa; }
input[type=range] { flex: 1; }
button {
flex: 1; padding: 12px; font-size: 1rem; border: none; border-radius: 6px;
background: #f5a623; color: #111; font-weight: 600; cursor: pointer;
}
button.secondary { background: #333; color: #eee; }
.log {
width: 100%; max-width: 480px; margin-top: 12px; font-size: 0.8rem;
color: #888; max-height: 120px; overflow-y: auto; border-top: 1px solid #333; padding-top: 8px;
}
.status { font-size: 0.8rem; color: #666; margin-top: 4px; }
</style>
</head>
<body>
<h1>🍺 Tap Usage Counter</h1>
<div class="video-wrap">
<video id="video" autoplay muted playsinline></video>
<canvas id="overlay" class="overlay"></canvas>
<div id="roiBox"></div>
</div>
<div class="counter" id="counter">0</div>
<div class="status" id="status">Drag the box over the tap/glass area, then Start.</div>
<div class="controls">
<div class="row">
<label>Sensitivity</label>
<input type="range" id="threshold" min="5" max="80" value="30">
<span id="thresholdVal">30</span>
</div>
<div class="row">
<label>Cooldown (s)</label>
<input type="range" id="cooldown" min="1" max="10" value="3">
<span id="cooldownVal">3</span>
</div>
<div class="row">
<button id="startBtn">Start Camera</button>
<button id="toggleBtn" class="secondary" disabled>Pause</button>
</div>
<div class="row">
<button id="resetBtn" class="secondary">Reset Count</button>
</div>
</div>
<div class="log" id="log"></div>
<script>
const video = document.getElementById('video');
const overlay = document.getElementById('overlay');
const ctx = overlay.getContext('2d', { willReadFrequently: true });
const roiBox = document.getElementById('roiBox');
const counterEl = document.getElementById('counter');
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
const thresholdInput = document.getElementById('threshold');
const cooldownInput = document.getElementById('cooldown');
const startBtn = document.getElementById('startBtn');
const toggleBtn = document.getElementById('toggleBtn');
const resetBtn = document.getElementById('resetBtn');
let count = 0;
let running = false;
let paused = false;
let stream = null;
let prevFrame = null;
let lastTrigger = 0;
let rafId = null;
// ROI in normalized (0-1) coords relative to video-wrap
let roi = { x: 0.3, y: 0.3, w: 0.4, h: 0.4 };
thresholdInput.oninput = () => document.getElementById('thresholdVal').textContent = thresholdInput.value;
cooldownInput.oninput = () => document.getElementById('cooldownVal').textContent = cooldownInput.value;
function layoutRoiBox() {
const wrap = video.parentElement;
const w = wrap.clientWidth, h = wrap.clientHeight;
roiBox.style.left = (roi.x * w) + 'px';
roiBox.style.top = (roi.y * h) + 'px';
roiBox.style.width = (roi.w * w) + 'px';
roiBox.style.height = (roi.h * h) + 'px';
}
window.addEventListener('resize', layoutRoiBox);
// Simple drag + resize via corner
let dragState = null;
roiBox.addEventListener('pointerdown', (e) => {
const rect = roiBox.getBoundingClientRect();
const nearRight = e.clientX > rect.right - 20;
const nearBottom = e.clientY > rect.bottom - 20;
dragState = {
mode: (nearRight && nearBottom) ? 'resize' : 'move',
startX: e.clientX, startY: e.clientY,
roi: { ...roi }
};
roiBox.setPointerCapture(e.pointerId);
});
roiBox.addEventListener('pointermove', (e) => {
if (!dragState) return;
const wrap = video.parentElement;
const w = wrap.clientWidth, h = wrap.clientHeight;
const dx = (e.clientX - dragState.startX) / w;
const dy = (e.clientY - dragState.startY) / h;
if (dragState.mode === 'move') {
roi.x = Math.min(Math.max(0, dragState.roi.x + dx), 1 - roi.w);
roi.y = Math.min(Math.max(0, dragState.roi.y + dy), 1 - roi.h);
} else {
roi.w = Math.min(Math.max(0.1, dragState.roi.w + dx), 1 - roi.x);
roi.h = Math.min(Math.max(0.1, dragState.roi.h + dy), 1 - roi.y);
}
layoutRoiBox();
});
roiBox.addEventListener('pointerup', () => dragState = null);
function log(msg) {
const t = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.textContent = `[${t}] ${msg}`;
logEl.prepend(div);
}
async function startCamera() {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 480 } }
});
video.srcObject = stream;
await video.play();
layoutRoiBox();
running = true;
paused = false;
startBtn.disabled = true;
toggleBtn.disabled = false;
statusEl.textContent = 'Watching for pours...';
loop();
} catch (err) {
statusEl.textContent = 'Camera error: ' + err.message;
}
}
function loop() {
if (!running) return;
if (!paused) analyzeFrame();
rafId = requestAnimationFrame(loop);
}
function analyzeFrame() {
if (video.readyState < 2) return;
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return;
// sample ROI at low res for performance
const sampleW = 80, sampleH = 80;
overlay.width = sampleW; overlay.height = sampleH;
const sx = roi.x * vw, sy = roi.y * vh, sw = roi.w * vw, sh = roi.h * vh;
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, sampleW, sampleH);
const frame = ctx.getImageData(0, 0, sampleW, sampleH).data;
if (prevFrame) {
let diffSum = 0;
for (let i = 0; i < frame.length; i += 4) {
const dr = Math.abs(frame[i] - prevFrame[i]);
const dg = Math.abs(frame[i+1] - prevFrame[i+1]);
const db = Math.abs(frame[i+2] - prevFrame[i+2]);
diffSum += (dr + dg + db);
}
const avgDiff = diffSum / (sampleW * sampleH * 3);
const threshold = parseInt(thresholdInput.value, 10);
const now = Date.now();
const cooldownMs = parseInt(cooldownInput.value, 10) * 1000;
if (avgDiff > threshold && (now - lastTrigger) > cooldownMs) {
lastTrigger = now;
count++;
counterEl.textContent = count;
log(`Pour detected (motion score ${avgDiff.toFixed(1)})`);
}
}
prevFrame = frame;
}
startBtn.onclick = startCamera;
toggleBtn.onclick = () => {
paused = !paused;
toggleBtn.textContent = paused ? 'Resume' : 'Pause';
statusEl.textContent = paused ? 'Paused.' : 'Watching for pours...';
};
resetBtn.onclick = () => {
count = 0;
counterEl.textContent = 0;
logEl.innerHTML = '';
log('Counter reset');
};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment