← back to Dw Photo Capture
public/cam.html
213 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DW Cam">
<meta name="theme-color" content="#0f0e0c">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<title>DW Remote Cam</title>
<style>
:root{ --bg:#0f0e0c; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#000;color:var(--ink);font:15px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;-webkit-text-size-adjust:100%;overscroll-behavior:none}
#stage{position:fixed;inset:0;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden}
video{width:100%;height:100%;object-fit:contain;background:#000}
/* flash on capture */
#flash{position:fixed;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .12s;z-index:40}
#flash.on{opacity:.85;transition:none}
/* top status bar */
.bar{position:fixed;left:0;right:0;top:0;z-index:30;display:flex;align-items:center;gap:10px;
padding:max(10px,env(safe-area-inset-top)) 14px 10px;background:linear-gradient(#000c,transparent)}
.logo{font:700 14px/1 "SF Pro Display",sans-serif;letter-spacing:.06em;text-transform:uppercase}
.logo b{color:var(--gold)}
.ver{font:600 9px/1 ui-monospace,Menlo,monospace;background:#1b1407;color:var(--gold);border-radius:99px;padding:3px 6px}
.pill{margin-left:auto;display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:600;
background:#16140fcc;border:1px solid var(--line);border-radius:99px;padding:6px 11px;backdrop-filter:blur(6px)}
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 0 transparent}
.dot.ok{background:var(--green);box-shadow:0 0 10px var(--green)}
.dot.warn{background:var(--gold)}
.dot.err{background:var(--red)}
/* bottom info */
.foot{position:fixed;left:0;right:0;bottom:0;z-index:30;padding:14px 14px max(14px,env(safe-area-inset-bottom));
background:linear-gradient(transparent,#000d);display:flex;flex-direction:column;gap:8px;align-items:center}
.target{font-size:13px;color:var(--muted);text-align:center;min-height:18px}
.target b{color:var(--gold);font:600 13px/1 ui-monospace,Menlo,monospace}
.hint{font-size:12px;color:var(--muted);text-align:center;max-width:340px}
.btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:13px 22px;font-weight:700;font-size:15px;cursor:pointer}
.btn.primary{background:var(--gold);color:#1b1407;border-color:var(--gold)}
.overlay{position:fixed;inset:0;z-index:50;background:#0f0e0cf2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;text-align:center}
.overlay[hidden]{display:none}
.overlay h2{font:700 19px/1.2 "SF Pro Display",sans-serif;margin:0;color:var(--gold)}
.overlay p{color:var(--muted);margin:0;max-width:340px}
.toast{position:fixed;left:50%;bottom:120px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:10px 16px;border-radius:12px;font-size:14px;z-index:60;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:1}
</style>
</head>
<body>
<div id="stage"><video id="v" playsinline autoplay muted></video></div>
<div id="flash"></div>
<div class="bar">
<span class="logo"><b>DW</b> Remote Cam</span>
<span class="ver">__VER__</span>
<span class="pill"><span class="dot" id="dCam"></span><span id="sCam">starting…</span></span>
</div>
<div class="foot">
<div class="target" id="target">Desktop not connected yet</div>
<div class="hint" id="hint">Keep this page open and your phone awake. The desktop will trigger the shot.</div>
</div>
<!-- camera permission / wake gate -->
<div class="overlay" id="gate">
<h2 id="gTitle">Start the camera</h2>
<p id="gMsg">Tap below and allow camera access. Point the phone at the wallcovering roll or sheet — the desktop fires the shutter.</p>
<button class="btn primary" id="gBtn">📷 Start Camera</button>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = s => document.querySelector(s);
let stream = null, videoTrack = null, camLive = false, busy = false;
let curTarget = null; // { dw_sku, product_id, keep_images, meta }
let lastNonce = 0;
function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2200); }
function setCam(state, label){
const d=$('#dCam'); d.className='dot'+(state?' '+state:'');
$('#sCam').textContent=label;
}
// ── getUserMedia: rear camera, hardened for iOS Safari ──
// 1) try exact environment (true rear), 2) fall back to environment, 3) any camera.
async function startCamera(){
if(stream){ return; }
$('#gBtn').textContent='Starting…';
const attempts = [
{ video:{ facingMode:{ exact:'environment' }, width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
{ video:{ facingMode:'environment', width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
{ video:true, audio:false }
];
let err=null;
for(const c of attempts){
try{ stream = await navigator.mediaDevices.getUserMedia(c); break; }
catch(e){ err=e; }
}
if(!stream){
$('#gTitle').textContent='Camera blocked';
$('#gMsg').textContent=(err&&err.name==='NotAllowedError')
? 'Camera permission was denied. In Safari: aA menu → Website Settings → Camera → Allow, then reload.'
: 'Could not start the camera ('+((err&&err.name)||'error')+'). This page needs HTTPS (or localhost) for the camera on iOS Safari.';
$('#gBtn').textContent='Try Again';
return;
}
const v=$('#v'); v.srcObject=stream;
videoTrack = stream.getVideoTracks()[0];
// keep it playing (iOS pauses on backgrounding) + reflect resolution
v.onloadedmetadata=()=>{ v.play().catch(()=>{}); reportLive(); };
// surface track ending (OS reclaimed the camera, e.g. another app)
videoTrack.addEventListener('ended', ()=>{ camLive=false; setCam('err','camera stopped'); reportLive(); $('#gate').hidden=false; $('#gTitle').textContent='Camera stopped'; $('#gMsg').textContent='The camera was released (phone slept or another app grabbed it). Tap to resume.'; $('#gBtn').textContent='📷 Resume Camera'; });
$('#gate').hidden=true;
camLive=true;
reportLive();
}
function camResolution(){
const v=$('#v');
// settings is most reliable for true sensor res; fall back to the video element
const s = videoTrack && videoTrack.getSettings ? videoTrack.getSettings() : {};
const w = s.width || v.videoWidth || 0, h = s.height || v.videoHeight || 0;
const fm = s.facingMode || '';
return { w, h, label: (w&&h?`${w}×${h}`:'live') + (fm?(' · '+fm):'') };
}
function reportLive(){
const r=camResolution();
camLive = !!(stream && videoTrack && videoTrack.readyState==='live' && r.w>0);
setCam(camLive?'ok':'warn', camLive?('camera live · '+r.label):'camera idle');
fetch('/api/pair/cam-status',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ live:camLive, info:r.label })}).catch(()=>{});
}
// ── Capture a frame at native resolution (NEVER upscale) ──
function captureDataUrl(){
const v=$('#v');
const r=camResolution();
const w = r.w || v.videoWidth, h = r.h || v.videoHeight;
if(!w||!h) return null;
const c=document.createElement('canvas'); c.width=w; c.height=h; // exact native res, no scaling
c.getContext('2d').drawImage(v, 0, 0, w, h);
return c.toDataURL('image/jpeg', 0.92);
}
// ── Handle a SHOOT signal from the desktop ──
async function onShoot(target){
if(busy) return;
if(!camLive){ toast('Camera not live'); reportLive(); return; }
if(target.nonce && target.nonce===lastNonce) return; // de-dupe repeated SSE delivery
lastNonce = target.nonce || Date.now();
busy=true;
// visual + capture
const f=$('#flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),120);
if(navigator.vibrate) try{ navigator.vibrate(40);}catch(e){}
const dataUrl = captureDataUrl();
if(!dataUrl){ toast('Capture failed'); busy=false; return; }
toast('Captured — uploading…');
// Route into the SAME existing attach pipeline as a manual capture: /api/photo.
// keep_images defaults true (preserve existing imagery — safe for live SKUs).
// If no target SKU was selected, the photo still lands locally (push:false).
let res;
try{
const payload = target.dw_sku
? { dw_sku:target.dw_sku, product_id:target.product_id||null, dataUrl,
keep_images: target.keep_images!==false, meta: target.meta||null }
: { dw_sku:'REMOTE-'+Date.now(), dataUrl, push:false }; // no SKU → local only, no Shopify write
const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
res=await r.json();
}catch(e){ res={ ok:false, err:'upload failed — check connection' }; }
// tell the desktop what happened so it can preview + show the attach outcome
fetch('/api/pair/shot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({
dw_sku: target.dw_sku||null, product_id: res&&res.product_id||target.product_id||null
}, res||{}))}).catch(()=>{});
toast(res&&res.ok ? (res.live?'🟢 LIVE · sent to desktop':'✓ Uploaded · sent to desktop') : ('Upload error'+(res&&res.err?': '+res.err:'')));
busy=false;
}
// ── SSE link to the server (auto-reconnects natively) ──
let es=null;
function connect(){
try{ if(es) es.close(); }catch(e){}
es=new EventSource('/cam/events');
es.addEventListener('shoot', e=>{ try{ onShoot(JSON.parse(e.data)); }catch(err){} });
es.addEventListener('status', e=>{ try{ applyStatus(JSON.parse(e.data)); }catch(err){} });
es.onerror=()=>{ /* EventSource auto-reconnects per the server's retry: hint */ };
}
function applyStatus(s){
$('#target').innerHTML = s.desk_connected
? (s.last_shot && s.last_shot.dw_sku ? ('Last: <b>'+s.last_shot.dw_sku+'</b> '+(s.last_shot.live?'🟢 live':(s.last_shot.ok?'✓ uploaded':'✗ failed'))) : 'Desktop connected — waiting for SHOOT')
: 'Desktop not connected yet';
}
// ── Lifecycle hardening: re-assert the stream when the page comes back to foreground ──
document.addEventListener('visibilitychange', ()=>{
if(document.visibilityState==='visible'){
const v=$('#v');
if(stream){ v.play().catch(()=>{}); reportLive(); }
}
});
// re-report camera state periodically so the desktop's "camera live" pill stays honest
setInterval(()=>{ if(stream) reportLive(); }, 7000);
$('#gBtn').addEventListener('click', startCamera);
connect();
// Wake Lock (where supported) keeps the screen on so the stream doesn't pause.
let wakeLock=null;
async function requestWake(){ try{ if('wakeLock' in navigator){ wakeLock=await navigator.wakeLock.request('screen'); } }catch(e){} }
document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible') requestWake(); });
requestWake();
</script>
</body>
</html>