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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
📷 Take Photo
|
||||
</button>
|
||||
|
||||
<!-- Hidden file input as fallback -->
|
||||
<input
|
||||
type="file"
|
||||
id="camera-input"
|
||||
@@ -21,11 +22,6 @@
|
||||
capture="environment"
|
||||
style="display: none;"
|
||||
>
|
||||
|
||||
<!-- Session counter shown during continuous capture -->
|
||||
<div class="capture-session-bar" id="capture-session-bar" style="display:none">
|
||||
<span id="session-count">0</span> photos this session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-photos" id="recent-photos">
|
||||
@@ -34,9 +30,34 @@
|
||||
<p class="empty-state">No photos captured yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capture toast -->
|
||||
<div class="capture-toast" id="capture-toast"></div>
|
||||
<!-- Fullscreen Camera Viewfinder -->
|
||||
<div class="camera-viewfinder" id="camera-viewfinder" style="display:none">
|
||||
<video id="camera-video" autoplay playsinline muted></video>
|
||||
|
||||
<!-- Flash overlay for shutter feedback -->
|
||||
<div class="camera-flash" id="camera-flash"></div>
|
||||
|
||||
<div class="camera-top-bar">
|
||||
<button class="camera-ui-btn camera-close-btn" id="camera-close-btn">×</button>
|
||||
<span class="camera-counter" id="camera-counter"></span>
|
||||
<div style="width:48px"></div>
|
||||
</div>
|
||||
|
||||
<div class="camera-bottom-bar">
|
||||
<div style="width:48px"></div>
|
||||
<button class="camera-shutter-btn" id="camera-shutter-btn">
|
||||
<span class="shutter-ring"></span>
|
||||
</button>
|
||||
<button class="camera-ui-btn camera-flip-btn" id="camera-flip-btn">🔄</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden canvas for frame capture -->
|
||||
<canvas id="camera-canvas" style="display:none"></canvas>
|
||||
|
||||
<!-- Toast inside viewfinder -->
|
||||
<div class="camera-toast" id="camera-toast"></div>
|
||||
</div>
|
||||
{% 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;
|
||||
}
|
||||
</style>
|
||||
{% 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 = '<div style="margin-bottom:5px;">' + status.join('<br>') + '</div>' +
|
||||
'<button onclick="manualSync()" style="background:lime;color:black;border:none;padding:10px;border-radius:5px;cursor:pointer;font-weight:bold;width:100%;">FORCE SYNC</button>' +
|
||||
'<div id="sync-status" style="margin-top:5px;color:yellow;"></div>';
|
||||
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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user