Created
August 11, 2026 22:50
-
-
Save jalberto/0bf514ea54f3d7aa9d534c246d43bb07 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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