← back to Dw Photo Capture

public/cam.html

486 lines

<!doctype html>
<html lang="en">
<head><script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<script src="/js/capture-pipeline.js"></script>
<script src="/js/acquire-camera.js"></script>
<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">
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
<title>DW Remote Cam</title>
<style>
  :root{ --ui-scale:1.35; /* "make all larger" — single dial for control/label size (1 = original). */
         --bg:#0f0e0c; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
  /* Full-screen elements are position:fixed;inset:0, so root zoom magnifies controls without overflow. */
  html{ zoom: var(--ui-scale); }
  *{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}
  /* TK-12228: the last shot, as stored on the server, shown on the phone too (tap to enlarge) */
  .lastshot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
  .lastshot[hidden]{display:none}
  .lastshot img{width:64px;height:64px;border-radius:10px;border:1px solid var(--line);object-fit:cover;background:#16140f;cursor:zoom-in}
  #camLb{position:fixed;inset:0;z-index:70;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px}
  #camLb[hidden]{display:none}
  #camLb img{max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px}
  .btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:15px 22px;font-weight:700;font-size:16px;min-height:54px;display:inline-flex;align-items:center;justify-content:center;gap:6px;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}
  /* Preview canvas (WYSIWYG overlay on video) */
  #preview{position:fixed;inset:0;object-fit:contain;pointer-events:none;z-index:2}
  /* Adjust panel — bottom sheet, collapsible */
  #adjPanelWrap{position:fixed;bottom:0;left:0;right:0;z-index:25;max-height:70vh;background:linear-gradient(#000,#1b1407);border-top:1px solid var(--line);display:flex;flex-direction:column}
  #adjHead{padding:12px 14px;display:flex;align-items:center;gap:10px;font-weight:600;cursor:pointer;background:#0f0e0c}
  #adjHead:active{background:#1b1407}
  #adjIcon{font-size:20px;transition:transform .2s}
  #adjIcon.open{transform:rotate(180deg)}
  #adjBody{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px}
  #adjBody[hidden]{display:none}
  .adj-group{display:flex;flex-direction:column;gap:8px}
  .adj-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;font-weight:600}
  .adj-sld{display:flex;align-items:center;gap:8px;height:28px}
  .adj-sld input[type=range]{flex:1;accent-color:var(--gold);height:6px;border-radius:3px}
  .adj-sld .val{font-size:12px;min-width:32px;text-align:right;color:var(--gold);font-weight:600}
  .adj-note{display:block;margin-top:-2px;font-size:10px;line-height:1.3;color:var(--muted);font-style:italic}
  select{appearance:none;background:#16140f;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:14px;font-weight:600;cursor:pointer;flex:1}
  select:focus{outline:1px solid var(--gold)}
  #adjHist{width:100%;height:40px;border:1px solid var(--line);border-radius:4px;background:#0a0905}
  #adjClip{display:inline-block;font-size:11px;color:var(--red);margin-top:4px;font-weight:600}
</style>
</head>
<body>
  <div id="stage"><video id="v" playsinline autoplay muted></video><canvas id="preview"></canvas></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 class="lastshot" id="lastShot" hidden><img id="lastShotImg" alt="last shot"><span id="lastShotTxt"></span></div>
  </div>
  <div id="camLb" hidden><img id="camLbImg" alt="last shot full size"><button class="btn" id="camLbClose">✕ Close</button></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>

  <!-- Adjust panel: preset select, sliders, histogram, controls -->
  <div id="adjPanelWrap">
    <div id="adjHead">
      <span id="adjIcon" class="open">▼</span>
      <span>Adjustments</span>
    </div>
    <div id="adjBody">
      <div class="adj-group">
        <label class="adj-label">Preset</label>
        <select id="adjPreset">
          <option value="custom">Custom</option>
          <option value="showroom">Showroom</option>
          <option value="daylight">Daylight</option>
          <option value="label-closeup">Label Close-up</option>
        </select>
      </div>

      <canvas id="adjHist" width="220" height="40"></canvas>
      <span id="adjClip" hidden>⚠ Clipping</span>

      <div class="adj-group">
        <label class="adj-label">Exposure</label>
        <div class="adj-sld"><input type="range" id="adjExposure" min="-100" max="100" value="0" step="1"><span class="val" id="valExposure">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Contrast</label>
        <div class="adj-sld"><input type="range" id="adjContrast" min="-100" max="100" value="0" step="1"><span class="val" id="valContrast">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Highlights</label>
        <div class="adj-sld"><input type="range" id="adjHighlights" min="-100" max="100" value="0" step="1"><span class="val" id="valHighlights">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Shadows</label>
        <div class="adj-sld"><input type="range" id="adjShadows" min="-100" max="100" value="0" step="1"><span class="val" id="valShadows">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Temperature</label>
        <div class="adj-sld"><input type="range" id="adjTemp" min="-100" max="100" value="0" step="1"><span class="val" id="valTemp">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Tint</label>
        <div class="adj-sld"><input type="range" id="adjTint" min="-100" max="100" value="0" step="1"><span class="val" id="valTint">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Saturation</label>
        <div class="adj-sld"><input type="range" id="adjSaturation" min="-100" max="100" value="0" step="1"><span class="val" id="valSaturation">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Vibrance</label>
        <div class="adj-sld"><input type="range" id="adjVibrance" min="-100" max="100" value="0" step="1"><span class="val" id="valVibrance">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Hue</label>
        <div class="adj-sld"><input type="range" id="adjHue" min="-180" max="180" value="0" step="1"><span class="val" id="valHue">0</span></div>
      </div>
      <div class="adj-group">
        <label class="adj-label">Sharpness</label>
        <div class="adj-sld"><input type="range" id="adjSharpness" min="0" max="100" value="0" step="1"><span class="val" id="valSharpness">0</span></div>
        <small class="adj-note">Applies at capture — not shown in this preview</small>
      </div>

      <button class="btn" id="adjReset" style="margin-top:8px">Reset to Defaults</button>
    </div>
  </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;
let _tune = CapturePipeline.defaultTune();
let _preset = 'custom';
let _wbGains = null;              // { rGain, gGain, bGain }
let _previewTicker = null;
const TS_MAX = 2000;              // max longest edge for capture (same as index.html)

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;
}

// Load/save tune from localStorage
function loadTune(){
  const saved = CapturePipeline.loadTune('dwCamTune');
  _tune = saved || CapturePipeline.defaultTune();
  _preset = localStorage.getItem('dwCamPreset') || 'custom';
  syncSliders();
}
function saveTune(){
  CapturePipeline.saveTune('dwCamTune', _tune);
  localStorage.setItem('dwCamPreset', _preset);
}

function syncSliders(){
  document.querySelectorAll('.adj-sld input').forEach(inp=>{
    const key = inp.id.replace('adj','').toLowerCase();
    const val = _tune[key];
    if(val!==undefined){ inp.value=val; $('#val'+inp.id.slice(3)).textContent=val; }
  });
  $('#adjPreset').value = _preset;
}

// WB tap-to-sample (same math as index.html)
$('#preview').addEventListener('click', (e)=>{
  const v=$('#v');
  const rect = v.getBoundingClientRect();
  const objFit = v.style.objectFit || 'contain';
  let sx=0,sy=0,sw=v.videoWidth,sh=v.videoHeight;
  if(objFit==='cover' || objFit==='contain'){
    const vAR = rect.width/rect.height, vidAR = sw/sh;
    if(objFit==='contain' ? vAR>vidAR : vAR<vidAR){
      const scaledH = rect.width/vidAR;
      sy += (rect.height-scaledH)/2/rect.height*sh;
      sh = scaledH/rect.height*sh;
    }else{
      const scaledW = rect.height*vidAR;
      sx += (rect.width-scaledW)/2/rect.width*sw;
      sw = scaledW/rect.width*sw;
    }
  }
  const px = (e.clientX-rect.left)/rect.width, py = (e.clientY-rect.top)/rect.height;
  const vx = sx + px*sw, vy = sy + py*sh;
  // Sample a 28×28 region
  const c = document.createElement('canvas');
  c.width = c.height = 28;
  c.getContext('2d').drawImage(v, Math.max(0,vx-14), Math.max(0,vy-14), 28, 28, 0, 0, 28, 28);
  const d = c.getContext('2d').getImageData(0,0,28,28).data;
  let r=0,g=0,b=0;
  for(let i=0;i<d.length;i+=4){ r+=d[i]; g+=d[i+1]; b+=d[i+2]; }
  const n = d.length/4;
  const avg = { r:Math.round(r/n), g:Math.round(g/n), b:Math.round(b/n) };
  const g0 = avg.g || 1;
  _wbGains = { rGain: Math.max(0.3, Math.min(3, g0/(avg.r||1))), gGain: 1, bGain: Math.max(0.3, Math.min(3, g0/(avg.b||1))) };
  toast(`WB tapped`);
});

// Preview tick: draw + bake preview canvas
function previewTick(){
  if(!camLive) return;
  const v=$('#v');
  const pc = $('#preview');
  const pw = Math.min(640, Math.max(v.videoWidth, v.videoHeight));
  const scale = pw / Math.max(v.videoWidth, v.videoHeight);
  pc.width = v.videoWidth * scale;
  pc.height = v.videoHeight * scale;
  const pctx = pc.getContext('2d', {willReadFrequently:true});
  CapturePipeline.drawSource(pctx, v, pc.width, pc.height, _tune.straighten);
  CapturePipeline.apply(pctx, pc.width, pc.height, _tune, {capture:false, preGain:_wbGains||null});
  // Update histogram
  const hist = CapturePipeline.histogram(pctx, pc.width, pc.height);
  const hc = $('#adjHist');
  CapturePipeline.drawHistogram(hc.getContext('2d'), hist);
  const clipWarn = $('#adjClip');
  if(hist.clipHigh > 0.02 || hist.clipLow > 0.02){ clipWarn.hidden=false; }
  else{ clipWarn.hidden=true; }
}

// ── getUserMedia: rear camera, hardened for iOS Safari ──
// TK-12127: unbounded getUserMedia() left "Starting…" showing forever on a WebKit hang, and a re-tap
// (nothing here disabled #gBtn) could fire a SECOND getUserMedia while the first was still pending —
// an orphaned hot stream if it ever resolved late. Ported the two-shot flow's proven acquireCamera
// helper (bounded 8s acquire race + generation-guarded late-release + bounded play()).
const camAcquirer = AcquireCamera.createCameraAcquirer();
function onCamTrackDead(){   // moved out of startCamera() only so it reads as one thing — same body as before
  camLive=false; setCam('err','camera stopped'); reportLive(); clearInterval(_previewTicker);
  $('#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';
}
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 }
  ];
  const v=$('#v');
  let res;
  try{
    res = await camAcquirer.acquire(attempts, { videoEl:v, onDead:onCamTrackDead });
  }catch(e){
    if(e && e.message==='camera-timeout'){
      // this IS the honest retry message — #gBtn is the only affordance this gate has (no separate
      // cancel control), so re-using its existing "Try Again" tap target is the correct fix, not a new UI.
      $('#gTitle').textContent='Camera took too long to respond';
      $('#gMsg').textContent='The camera didn’t start in time. Tap below to try again.';
      $('#gBtn').textContent='Try Again';
      return;
    }
    $('#gTitle').textContent='Camera blocked';
    $('#gMsg').textContent=(e&&e.name==='NotAllowedError')
      ? 'Camera permission was denied. In Safari: aA menu → Website Settings → Camera → Allow, then reload.'
      : 'Could not start the camera ('+((e&&e.name)||'error')+'). This page needs HTTPS (or localhost) for the camera on iOS Safari.';
    $('#gBtn').textContent='Try Again';
    return;
  }
  stream = res.stream;
  videoTrack = res.track;
  // Start preview tick + hardware layer — no longer gated on onloadedmetadata: the helper already
  // awaited a bounded play() above, so by the time acquire() resolves the video is confirmed playing.
  clearInterval(_previewTicker);
  _previewTicker = setInterval(previewTick, 80);
  const caps = CapturePipeline.Hardware.probe(videoTrack);
  if(Object.keys(caps).length>0){
    let hwDelay=null;
    const updateHW=()=>{ clearTimeout(hwDelay); hwDelay=setTimeout(()=>{ CapturePipeline.Hardware.apply(videoTrack, _tune); }, 300); };
    document.querySelectorAll('.adj-sld input').forEach(inp=>inp.addEventListener('input', updateHW));
  }
  $('#gate').hidden=true;
  camLive=true;
  reportLive();
}
function camResolution(){
  const v=$('#v');
  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, tune:_tune })}).catch(()=>{});
}

// ── Capture a frame at native resolution (NEVER upscale) — NOW WITH FULL BAKE ──
function captureDataUrl(){
  const v=$('#v');
  const r=camResolution();
  let w = r.w || v.videoWidth, h = r.h || v.videoHeight;
  if(!w||!h) return null;
  // Cap longest edge at TS_MAX
  const maxEdge = Math.max(w, h);
  if(maxEdge > TS_MAX){
    const scale = TS_MAX / maxEdge;
    w = Math.round(w * scale);
    h = Math.round(h * scale);
  }
  const c=document.createElement('canvas'); c.width=w; c.height=h;
  const ctx = c.getContext('2d', {willReadFrequently:true});
  CapturePipeline.drawSource(ctx, v, w, h, _tune.straighten);
  CapturePipeline.apply(ctx, w, h, _tune, {capture:true, preGain:_wbGains||null});
  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;
  lastNonce = target.nonce || Date.now();
  busy=true;
  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…');
  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 };
    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' }; }
  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:'')));
  showLastShot(res&&res.ok&&res.photo ? res.photo : dataUrl, res&&res.ok ? (target.dw_sku||'saved') : 'not uploaded — local only');
  busy=false;
}
// TK-12228: show the shot on the phone — the server-stored copy when the upload worked, else the local capture.
function showLastShot(src, label){ if(!src) return; $('#lastShotImg').src=src; $('#lastShotTxt').textContent='Last shot · '+label; $('#lastShot').hidden=false; }
$('#lastShotImg').addEventListener('click',()=>{ $('#camLbImg').src=$('#lastShotImg').src; $('#camLb').hidden=false; });
$('#camLbClose').addEventListener('click',()=>{ $('#camLb').hidden=true; });

// ── SSE + status ──
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=()=>{};
}
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';
}

// ── Panel collapse/expand ──
$('#adjHead').addEventListener('click', ()=>{
  const body=$('#adjBody');
  const icon=$('#adjIcon');
  body.hidden = !body.hidden;
  icon.classList.toggle('open');
});

// ── Slider + preset wiring ──
document.querySelectorAll('.adj-sld input').forEach(inp=>{
  inp.addEventListener('input', ()=>{
    const key = inp.id.replace('adj','').toLowerCase();
    const val = parseInt(inp.value);
    _tune[key] = val;
    $('#val'+inp.id.slice(3)).textContent = val;
    _preset = 'custom';
    $('#adjPreset').value = 'custom';
    saveTune();
  });
});

$('#adjPreset').addEventListener('change', (e)=>{
  if(e.target.value==='custom'){ _preset='custom'; saveTune(); return; }
  _tune = CapturePipeline.applyPreset(_tune, e.target.value);
  _preset = e.target.value;
  syncSliders();
  saveTune();
});

$('#adjReset').addEventListener('click', ()=>{
  _tune = CapturePipeline.defaultTune();
  _preset = 'custom';
  syncSliders();
  $('#adjPreset').value = 'custom';
  saveTune();
  toast('Reset to defaults');
});

// ── Lifecycle ──
document.addEventListener('visibilitychange', ()=>{
  if(document.visibilityState==='visible'){
    const v=$('#v');
    if(stream){ v.play().catch(()=>{}); reportLive(); }
  }
});
setInterval(()=>{ if(stream) reportLive(); }, 7000);

$('#gBtn').addEventListener('click', startCamera);
loadTune();
connect();
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>