From e37cf878d0c7af2c7b8536287aa8f29aceabb3b6 Mon Sep 17 00:00:00 2001 From: kamaji Date: Sat, 7 Feb 2026 04:56:16 -0600 Subject: [PATCH] Replace file-input capture with live camera viewfinder The previous approach used setTimeout to re-trigger the file input after each capture, which iOS Safari blocks because it loses the user gesture context. Now uses getUserMedia for a fullscreen camera viewfinder that stays open between shots. Shutter button captures frames from the video stream, saves to IndexedDB, and the camera remains open until the user taps close. Includes flash feedback, session counter, and camera flip. Falls back to single-shot file input if getUserMedia is unavailable. Co-Authored-By: Claude Opus 4.6 --- app/templates/capture.html | 472 +++++++++++++++++++++++++------------ 1 file changed, 324 insertions(+), 148 deletions(-) diff --git a/app/templates/capture.html b/app/templates/capture.html index 2a896b4..b5d17c5 100644 --- a/app/templates/capture.html +++ b/app/templates/capture.html @@ -14,6 +14,7 @@ 📷 Take Photo + - - -
@@ -34,9 +30,34 @@

No photos captured yet

+ - -
+ + {% endblock %} @@ -77,33 +98,6 @@ cursor: not-allowed; } -.capture-session-bar { - margin-top: 0.75rem; - padding: 0.5rem 1rem; - background: var(--bg-secondary); - border-radius: 8px; - color: var(--accent); - font-weight: 500; - font-size: 0.95rem; - display: inline-block; -} - -.capture-toast { - position: fixed; - top: 70px; - left: 50%; - transform: translateX(-50%); - padding: 0.6rem 1.25rem; - border-radius: 8px; - font-size: 0.9rem; - z-index: 9998; - display: none; - white-space: nowrap; - background: rgba(40, 167, 69, 0.9); - color: #fff; - pointer-events: none; -} - .recent-photos { margin-top: 3rem; padding-bottom: 2rem; @@ -164,6 +158,142 @@ .thumbnail .status-badge.verified { background: var(--success); } + +/* Camera Viewfinder */ +body.camera-open { + overflow: hidden; +} + +.camera-viewfinder { + position: fixed; + inset: 0; + background: #000; + z-index: 9999; + display: flex; + flex-direction: column; +} + +.camera-viewfinder video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.camera-flash { + position: absolute; + inset: 0; + background: #fff; + opacity: 0; + pointer-events: none; + z-index: 2; + transition: opacity 0.05s; +} + +.camera-flash.flash { + opacity: 0.6; +} + +.camera-top-bar { + position: relative; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + padding-top: max(0.5rem, env(safe-area-inset-top)); + background: rgba(0,0,0,0.4); +} + +.camera-ui-btn { + background: none; + border: none; + color: #fff; + font-size: 1.5rem; + padding: 0.5rem; + min-width: 48px; + min-height: 48px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.camera-close-btn { + font-size: 2.2rem; + line-height: 1; +} + +.camera-counter { + color: #fff; + font-size: 1rem; + font-weight: 600; +} + +.camera-bottom-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 2rem; + padding-bottom: max(1.5rem, env(safe-area-inset-bottom)); + background: rgba(0,0,0,0.4); +} + +.camera-shutter-btn { + width: 72px; + height: 72px; + border-radius: 50%; + border: 4px solid #fff; + background: rgba(255,255,255,0.2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +.camera-shutter-btn:active { + transform: scale(0.92); +} + +.camera-shutter-btn:disabled { + opacity: 0.4; +} + +.shutter-ring { + display: block; + width: 56px; + height: 56px; + border-radius: 50%; + background: #fff; +} + +.camera-flip-btn { + font-size: 1.5rem; +} + +.camera-toast { + position: absolute; + bottom: 140px; + left: 50%; + transform: translateX(-50%); + padding: 0.5rem 1.25rem; + border-radius: 8px; + font-size: 0.9rem; + z-index: 4; + display: none; + white-space: nowrap; + background: rgba(40, 167, 69, 0.85); + color: #fff; + pointer-events: none; +} {% endblock %} @@ -191,111 +321,194 @@ Storage.init().then(() => { const captureBtn = document.getElementById('capture-btn'); const cameraInput = document.getElementById('camera-input'); -// Continuous capture state +// ---- Camera state ---- +let cameraStream = null; +let facingMode = 'environment'; let sessionCount = 0; -let continuousCapture = false; -let processingPhoto = false; +let shutterBusy = false; -captureBtn.addEventListener('click', function() { - sessionCount = 0; - updateSessionCounter(); - cameraInput.click(); +// ---- Main button: open live viewfinder, fallback to file input ---- +captureBtn.addEventListener('click', async function() { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + await openCamera(); + } else { + // Fallback: use file input + cameraInput.click(); + } }); +// ---- File input fallback (single-shot) ---- cameraInput.addEventListener('change', async function(e) { const file = e.target.files[0]; - if (!file) { - // User cancelled the camera — stop continuous capture - continuousCapture = false; - if (sessionCount > 0) { - showCaptureToast(sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') + ' captured'); - } - return; - } + if (!file) return; - processingPhoto = true; captureBtn.disabled = true; captureBtn.textContent = '⏳ Processing...'; try { const jpegBlob = await convertToJPEG(file); - - const now = new Date(); - const timestamp = now.getFullYear() + - String(now.getMonth() + 1).padStart(2, '0') + - String(now.getDate()).padStart(2, '0') + '_' + - String(now.getHours()).padStart(2, '0') + - String(now.getMinutes()).padStart(2, '0') + - String(now.getSeconds()).padStart(2, '0'); - const filename = currentUsername + '_' + timestamp + '.jpg'; - const targetPath = localStorage.getItem('nextsnap_upload_path') || '/'; - - if (storageReady) { - await Storage.savePhoto({ - username: currentUsername, - timestamp: Date.now(), - filename: filename, - targetPath: targetPath, - blob: jpegBlob, - status: 'pending' - }); - - if (navigator.onLine && typeof SyncEngine !== 'undefined') { - SyncEngine.triggerSync(); - } - - loadRecentPhotos(); - } - - sessionCount++; - continuousCapture = true; - updateSessionCounter(); - showCaptureToast('Photo saved'); - - captureBtn.disabled = false; - captureBtn.textContent = '📷 Take Photo'; - - } catch (error) { - console.error('Error:', error); - captureBtn.textContent = '❌ Error'; - continuousCapture = false; + await savePhoto(jpegBlob); + captureBtn.textContent = '✓ Photo Saved!'; + loadRecentPhotos(); setTimeout(() => { captureBtn.disabled = false; captureBtn.textContent = '📷 Take Photo'; - }, 2000); + }, 1000); + } catch (error) { + console.error('Error:', error); alert('Failed: ' + error.message); + captureBtn.disabled = false; + captureBtn.textContent = '📷 Take Photo'; } - processingPhoto = false; e.target.value = ''; - - // Re-open camera automatically for next photo - if (continuousCapture) { - setTimeout(() => { - cameraInput.click(); - }, 300); - } }); -function updateSessionCounter() { - const bar = document.getElementById('capture-session-bar'); - const countEl = document.getElementById('session-count'); - if (sessionCount > 0) { - countEl.textContent = sessionCount; - bar.style.display = 'inline-block'; - } else { - bar.style.display = 'none'; +// ---- Live camera viewfinder ---- +async function openCamera() { + sessionCount = 0; + updateCameraCounter(); + + try { + cameraStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } }, + audio: false + }); + + const video = document.getElementById('camera-video'); + video.srcObject = cameraStream; + await video.play(); + + document.getElementById('camera-viewfinder').style.display = 'flex'; + document.body.classList.add('camera-open'); + } catch (err) { + console.error('getUserMedia failed:', err); + // Fallback to file input + cameraInput.click(); } } -function showCaptureToast(msg) { - const toast = document.getElementById('capture-toast'); - toast.textContent = '✓ ' + msg; +function closeCamera() { + if (cameraStream) { + cameraStream.getTracks().forEach(t => t.stop()); + cameraStream = null; + } + + const video = document.getElementById('camera-video'); + video.srcObject = null; + + document.getElementById('camera-viewfinder').style.display = 'none'; + document.body.classList.remove('camera-open'); + + loadRecentPhotos(); +} + +async function flipCamera() { + facingMode = facingMode === 'environment' ? 'user' : 'environment'; + + if (cameraStream) { + cameraStream.getTracks().forEach(t => t.stop()); + } + + try { + cameraStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: facingMode, width: { ideal: 3840 }, height: { ideal: 2160 } }, + audio: false + }); + const video = document.getElementById('camera-video'); + video.srcObject = cameraStream; + await video.play(); + } catch (err) { + console.error('Flip camera failed:', err); + showCameraToast('Could not switch camera'); + } +} + +async function takePhoto() { + if (shutterBusy) return; + shutterBusy = true; + + const shutterBtn = document.getElementById('camera-shutter-btn'); + shutterBtn.disabled = true; + + try { + const video = document.getElementById('camera-video'); + const canvas = document.getElementById('camera-canvas'); + + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0); + + // Flash feedback + const flash = document.getElementById('camera-flash'); + flash.classList.add('flash'); + setTimeout(() => flash.classList.remove('flash'), 150); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas capture failed')), 'image/jpeg', 0.92); + }); + + await savePhoto(blob); + sessionCount++; + updateCameraCounter(); + showCameraToast('Photo saved'); + + } catch (err) { + console.error('takePhoto error:', err); + showCameraToast('Error saving photo'); + } finally { + shutterBusy = false; + shutterBtn.disabled = false; + } +} + +// ---- Wire viewfinder buttons ---- +document.getElementById('camera-close-btn').addEventListener('click', closeCamera); +document.getElementById('camera-shutter-btn').addEventListener('click', takePhoto); +document.getElementById('camera-flip-btn').addEventListener('click', flipCamera); + +function updateCameraCounter() { + const el = document.getElementById('camera-counter'); + el.textContent = sessionCount > 0 ? sessionCount + ' photo' + (sessionCount !== 1 ? 's' : '') : ''; +} + +function showCameraToast(msg) { + const toast = document.getElementById('camera-toast'); + toast.textContent = msg; toast.style.display = 'block'; setTimeout(() => { toast.style.display = 'none'; }, 1500); } -const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB +// ---- Shared helpers ---- +async function savePhoto(jpegBlob) { + const now = new Date(); + const timestamp = now.getFullYear() + + String(now.getMonth() + 1).padStart(2, '0') + + String(now.getDate()).padStart(2, '0') + '_' + + String(now.getHours()).padStart(2, '0') + + String(now.getMinutes()).padStart(2, '0') + + String(now.getSeconds()).padStart(2, '0'); + const filename = currentUsername + '_' + timestamp + '.jpg'; + const targetPath = localStorage.getItem('nextsnap_upload_path') || '/'; + + if (storageReady) { + await Storage.savePhoto({ + username: currentUsername, + timestamp: Date.now(), + filename: filename, + targetPath: targetPath, + blob: jpegBlob, + status: 'pending' + }); + + if (navigator.onLine && typeof SyncEngine !== 'undefined') { + SyncEngine.triggerSync(); + } + } +} + +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; function convertToJPEG(file) { return new Promise((resolve, reject) => { @@ -318,10 +531,8 @@ function convertToJPEG(file) { return; } if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.3) { - console.log('Photo: ' + (blob.size / 1024 / 1024).toFixed(1) + 'MB, ' + canvas.width + 'x' + canvas.height + ', q=' + quality.toFixed(2)); resolve(blob); } else { - console.log('Photo too large (' + (blob.size / 1024 / 1024).toFixed(1) + 'MB), reducing...'); if (quality > 0.5) { quality -= 0.1; } else { @@ -368,40 +579,5 @@ async function loadRecentPhotos() { } console.log('Capture page loaded'); - -// Debug panel -setTimeout(() => { - const debugDiv = document.createElement('div'); - debugDiv.id = 'debug-panel'; - debugDiv.style.cssText = 'position:fixed;bottom:80px;right:10px;z-index:9999;background:black;color:lime;padding:10px;border-radius:5px;font-family:monospace;font-size:10px;max-width:300px;'; - - const status = []; - status.push('Storage: ' + (typeof Storage !== 'undefined' ? 'OK' : 'FAIL')); - status.push('SyncEngine: ' + (typeof SyncEngine !== 'undefined' ? 'OK' : 'FAIL')); - status.push('Dexie: ' + (typeof Dexie !== 'undefined' ? 'OK' : 'FAIL')); - - debugDiv.innerHTML = '
' + status.join('
') + '
' + - '' + - '
'; - document.body.appendChild(debugDiv); -}, 2000); - -window.manualSync = function() { - const statusDiv = document.getElementById('sync-status'); - statusDiv.textContent = 'Checking...'; - - if (typeof SyncEngine === 'undefined') { - statusDiv.textContent = 'ERROR: SyncEngine not loaded!'; - return; - } - - statusDiv.textContent = 'Triggering sync...'; - try { - SyncEngine.triggerSync(); - statusDiv.textContent = 'Sync triggered!'; - } catch (e) { - statusDiv.textContent = 'ERROR: ' + e.message; - } -}; {% endblock %}