Files
nextsnap/app/templates/capture.html
kamaji 5105b42c46 Prevent blob eviction: store as ArrayBuffer + request persistence
iOS Safari evicts Blob file-backed data from IndexedDB under memory
pressure, causing upload POSTs to throw 'Load failed' without ever
reaching the server. Two-pronged fix:

1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file
   reference) in IndexedDB — ArrayBuffers are not subject to eviction
2. Request navigator.storage.persist() to signal the browser not to
   evict our storage under pressure

Also adds Storage.getBlob() helper for converting stored ArrayBuffer
back to Blob at upload/display time, with backward compat for any
existing Blob-stored photos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:01:07 -06:00

650 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}Capture - NextSnap{% endblock %}
{% block content %}
<div class="container">
<div class="capture-section">
<p class="upload-path">
Uploading to: <strong><span id="current-path">/</span></strong>
<a href="/browser" class="change-link">Change</a>
</p>
<button class="btn btn-primary btn-capture" id="capture-btn">
📷 Take Photo
</button>
<button class="btn btn-secondary btn-upload" id="upload-btn">
🖼️ Upload from Library
</button>
<!-- Hidden file input as fallback for camera -->
<input
type="file"
id="camera-input"
accept="image/*"
capture="environment"
style="display: none;"
>
<!-- Hidden file input for photo library (multiple) -->
<input
type="file"
id="upload-input"
accept="image/*"
multiple
style="display: none;"
>
</div>
<div class="recent-photos" id="recent-photos">
<h3>Recent Photos</h3>
<div class="photo-thumbnails" id="photo-thumbnails">
<p class="empty-state">No photos captured yet</p>
</div>
</div>
</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">&times;</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 %}
{% block extra_css %}
<style>
.capture-section {
text-align: center;
margin: 2rem 0;
}
.upload-path {
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.upload-path strong {
color: var(--text-primary);
}
.change-link {
color: var(--accent);
margin-left: 0.5rem;
text-decoration: none;
}
.change-link:hover {
text-decoration: underline;
}
.btn-capture {
font-size: 1.25rem;
padding: 1.5rem;
margin: 1rem 0;
}
.btn-capture:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-upload {
font-size: 1.1rem;
padding: 1rem;
margin: 0.5rem 0;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--bg-tertiary);
}
.btn-upload:disabled {
opacity: 0.6;
}
.recent-photos {
margin-top: 3rem;
padding-bottom: 2rem;
}
.recent-photos h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.photo-thumbnails {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.photo-thumbnails .empty-state {
grid-column: 1 / -1;
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem;
font-size: 0.9rem;
}
.thumbnail {
aspect-ratio: 1;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail .status-badge {
position: absolute;
top: 4px;
right: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-primary);
}
.thumbnail .status-badge.pending {
background: var(--warning);
}
.thumbnail .status-badge.uploading {
background: var(--accent);
animation: pulse 1s infinite;
}
.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 %}
{% block extra_js %}
<script src="{{ url_for('static', filename='lib/dexie.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/storage.js') }}"></script>
<script src="{{ url_for('static', filename='js/sync.js') }}"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script>
const currentUsername = '{{ username }}';
function updateUploadPath() {
const savedPath = localStorage.getItem('nextsnap_upload_path') || '/';
document.getElementById('current-path').textContent = savedPath;
}
updateUploadPath();
let storageReady = false;
Storage.init().then(() => {
storageReady = true;
SyncEngine.init(currentUsername);
loadRecentPhotos();
}).catch(err => console.error('Storage init failed:', err));
const captureBtn = document.getElementById('capture-btn');
const cameraInput = document.getElementById('camera-input');
// ---- Camera state ----
let cameraStream = null;
let facingMode = 'environment';
let sessionCount = 0;
let shutterBusy = false;
// ---- 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) return;
captureBtn.disabled = true;
captureBtn.textContent = '⏳ Processing...';
try {
const jpegBlob = await convertToJPEG(file);
await savePhoto(jpegBlob);
captureBtn.textContent = '✓ Photo Saved!';
loadRecentPhotos();
setTimeout(() => {
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}, 1000);
} catch (error) {
console.error('Error:', error);
alert('Failed: ' + error.message);
captureBtn.disabled = false;
captureBtn.textContent = '📷 Take Photo';
}
e.target.value = '';
});
// ---- Upload from library ----
const uploadBtn = document.getElementById('upload-btn');
const uploadInput = document.getElementById('upload-input');
uploadBtn.addEventListener('click', function() {
uploadInput.click();
});
uploadInput.addEventListener('change', async function(e) {
const files = Array.from(e.target.files);
if (files.length === 0) return;
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Processing 0/' + files.length + '...';
let saved = 0;
for (const file of files) {
try {
const jpegBlob = await convertToJPEG(file);
await savePhoto(jpegBlob);
saved++;
uploadBtn.textContent = '⏳ Processing ' + saved + '/' + files.length + '...';
} catch (err) {
console.error('Failed to process file:', file.name, err);
}
}
uploadBtn.textContent = '✓ ' + saved + ' photo' + (saved !== 1 ? 's' : '') + ' added';
loadRecentPhotos();
setTimeout(() => {
uploadBtn.disabled = false;
uploadBtn.textContent = '🖼️ Upload from Library';
}, 1500);
e.target.value = '';
});
// ---- 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 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.97);
});
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);
}
// ---- 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') +
String(now.getMilliseconds()).padStart(3, '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 = 20 * 1024 * 1024;
function convertToJPEG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
let quality = 0.97;
let scale = 1.0;
const attempt = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to convert'));
return;
}
if (blob.size <= MAX_UPLOAD_BYTES || quality <= 0.5) {
resolve(blob);
} else {
if (quality > 0.85) {
quality -= 0.03;
} else {
scale *= 0.85;
quality = 0.92;
}
attempt();
}
}, 'image/jpeg', quality);
};
attempt();
};
img.onerror = () => reject(new Error('Failed to load'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read'));
reader.readAsDataURL(file);
});
}
async function loadRecentPhotos() {
if (!storageReady) return;
const container = document.getElementById('photo-thumbnails');
const photos = await Storage.getRecentPhotos(currentUsername, 5);
if (photos.length === 0) {
container.innerHTML = '<p class="empty-state">No photos captured yet</p>';
return;
}
container.innerHTML = '';
for (const photo of photos) {
const blob = Storage.getBlob(photo);
if (!blob) continue;
const url = URL.createObjectURL(blob);
const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'uploading' ? 'uploading' : 'pending';
const div = document.createElement('div');
div.className = 'thumbnail';
div.innerHTML = '<img src="' + url + '"><span class="status-badge ' + statusClass + '"></span>';
container.appendChild(div);
}
}
console.log('Capture page loaded');
</script>
{% endblock %}