/**
* Capture tab: one dial, four modes (DESIGN.md §11.4).
* Look — the God's-eye view projected around you (aircraft, satellites,
* ships) on the camera, aligned by compass + gyro. Drag to look on
* devices without motion sensors. Nothing is recorded.
* Witness — signed photo/video with IMU, GPS, heading and a hash chain.
* Live — a broadcast: you appear on the globe as an eye with a frustum,
* segments are sealed every 10 s into a hash chain.
* Upload — a photo or video from the library, EXIF read, placed on a
* mini-map, and marked Unverified everywhere.
* After any capture comes the attach sheet.
*/
import { icon } from './icons.js';
import {
$,
h,
esc,
clamp,
haptic,
toast,
fmtDist,
fmtAlt,
sparkPath,
reducedMotion,
} from './dom.js';
import { createCamera } from '../capture/camera.js';
import {
createSensors,
requestMotionPermission,
poseFromAzEl,
lookAngles,
projectDir,
dirFromAzEl,
} from '../capture/sensors.js';
import { seal, networkTimeOffset, sha256Hex } from '../capture/provenance.js';
import { createLiveSession, canBroadcast } from '../capture/live.js';
import { readExif } from '../capture/exif.js';
import { put } from '../capture/db.js';
import { createMiniMap } from './minimap.js';
import { llShort, cardinal } from './bands.js';
const KIND_COLOR = {
aircraft: '#7CC4FF',
helicopter: '#7CC4FF',
military: '#7CC4FF',
ship: '#38E1C6',
vessel: '#38E1C6',
satellite: '#C9A7FF',
station: '#FFFFFF',
quake: '#FF6A3D',
fire: '#FF6A3D',
};
const KIND_LABEL = {
aircraft: ['aircraft', 'aircraft'],
military: ['military', 'military'],
helicopter: ['helicopter', 'helicopters'],
ship: ['ship', 'ships'],
vessel: ['ship', 'ships'],
satellite: ['satellite', 'satellites'],
station: ['space station', 'space stations'],
};
const FALLBACK_POS = { lat: 37.7749, lon: -122.4194, alt: 16, simulated: true };
const GATE_KEY = 'gev.mobile.capture.gate';
export function createCapture(
view,
{ globe, onSealed, onVerify, rt, attach, getLocation },
) {
view.innerHTML = `
LAT—LON—±—HDG—PITCH—ROLL—
IMU |a|
Session idle
OFF AIRtap ● to go live${icon('users')}0 watching
STANDBY
${icon('upload')}
Add from your library
A photo or video you already have. We read its EXIF position and time if present, you place it on the map, and it is marked Unverified everywhere: without a capture-time seal it can be cited, never treated as an observation.
${icon('viewfinder')}
See the sky. Seal what you see.
Capture uses three sensors, only while this tab is open.
${icon('camera')}
CameraLook overlays aircraft, satellites and ships on the live view. Witness records the photo or clip you seal.
${icon('north')}
Motion & compassAligns the overlay with where your phone points, and becomes part of the signed motion trace.
${icon('location')}
LocationPlaces contacts around you, and stamps each capture with where you stood.
${icon('lock')} Captures stay on this device. Each one is signed with a key generated here that cannot be exported.
`;
const root = $('.cap', view);
const video = $('.cap-video', view);
const ar = $('.cap-ar', view);
const actx = ar.getContext('2d');
const tape = $('.cap-tape canvas', view);
const tctx = tape.getContext('2d');
const traceC = $('.hud-trace canvas', view);
const trctx = traceC.getContext('2d');
const gate = $('.cap-gate', view);
const sealEl = $('.cap-seal', view);
const status = $('.cap-status', view);
const shutter = $('.shutter', view);
const level = $('.cap-level i', view);
const camera = createCamera(video);
const sensors = createSensors();
let mode = 'look';
let active = false;
let started = false;
let camOk = false;
let raf = 0;
let sim = { az: 250, el: 14 };
let contacts = [];
let contactsAt = 0;
let W = 0;
let H = 0;
let dpr = 1;
let lastCapture = null;
let netOffset = null;
let target = null; // { s, replyTo, needEyes } — the room this capture answers
let live = null; // { session, id, t0, pin, frameTimer }
const VFOV = 62;
const MODES = ['look', 'witness', 'live', 'upload'];
const upload = $('.cap-upload', view);
const liveHud = $('.live-hud', view);
const targetChip = $('.cap-target', view);
/* ---------- lifecycle ---------- */
function resize() {
const r = root.getBoundingClientRect();
dpr = Math.min(2, devicePixelRatio || 1);
W = r.width;
H = r.height;
ar.width = Math.round(W * dpr);
ar.height = Math.round(H * dpr);
const tr = tape.getBoundingClientRect();
tape.width = Math.round(tr.width * dpr);
tape.height = Math.round(tr.height * dpr);
const hr = traceC.getBoundingClientRect();
traceC.width = Math.round(hr.width * dpr);
traceC.height = Math.round(hr.height * dpr);
}
new ResizeObserver(resize).observe(root);
async function begin({ withSensors }) {
gate.hidden = true;
try {
sessionStorage.setItem(GATE_KEY, '1');
} catch {
/* ignore */
}
started = true;
if (withSensors) {
const perm = await requestMotionPermission();
if (perm === 'denied')
showStatus('Motion access denied — drag to look around', 4000);
sensors.start();
try {
await camera.start();
camOk = true;
root.classList.add('has-camera');
} catch (err) {
camOk = false;
root.classList.remove('has-camera');
showStatus(
err?.name === 'NotAllowedError'
? 'Camera access was declined — showing a simulated sky'
: 'No camera here — showing a simulated sky',
4500,
);
}
}
networkTimeOffset().then((o) => (netOffset = o));
if (mode === 'witness') sensors.beginSession();
loop();
}
function enter() {
active = true;
resize();
setTimeout(() => needsLoop(), 0);
let seen = false;
try {
seen = sessionStorage.getItem(GATE_KEY) === '1';
} catch {
/* ignore */
}
if (!started && !seen) gate.hidden = false;
else if (!started) begin({ withSensors: true });
else {
if (camOk) camera.start().catch(() => {});
sensors.start();
if (mode === 'witness' && !sensors.recording) sensors.beginSession();
loop();
}
}
function leave() {
if (live) {
endLive();
toast('Broadcast ended and sealed — find it in You', {
icon: icon('sealFill'),
});
}
active = false;
cancelAnimationFrame(raf);
raf = 0;
camera.stop();
sensors.endSession();
sensors.stop();
}
$('.gate-go', view).addEventListener('click', () =>
begin({ withSensors: true }),
);
$('.gate-skip', view).addEventListener('click', () =>
begin({ withSensors: false }),
);
function showStatus(text, ms = 2500) {
status.textContent = text;
status.hidden = false;
clearTimeout(showStatus.t);
showStatus.t = setTimeout(() => (status.hidden = true), ms);
}
/* ---------- mode dial ---------- */
function setMode(m) {
if (m === mode || !MODES.includes(m)) return;
if (live && m !== 'live') {
showStatus('End the broadcast first');
return;
}
mode = m;
root.dataset.mode = m;
view
.querySelectorAll('.cap-modes [data-mode]')
.forEach((b) =>
b.setAttribute('aria-selected', String(b.dataset.mode === m)),
);
haptic('tick');
if (m === 'witness' && started) sensors.beginSession();
if (m === 'look' || m === 'upload') sensors.endSession();
upload.hidden = m !== 'upload';
const lbl = $('.shutter-label', view);
lbl.textContent = m === 'live' ? 'GO LIVE' : '';
shutter.setAttribute(
'aria-label',
m === 'live'
? 'Go live'
: m === 'upload'
? 'Choose a photo or video'
: 'Seal a photo. Hold for video',
);
if (m === 'live') updateLiveHud(pose());
}
$('.cap-modes', view).addEventListener('click', (e) => {
const b = e.target.closest('[data-mode]');
if (b) setMode(b.dataset.mode);
});
// Swipe the viewfinder sideways to change mode, like Camera.
let swipe = null;
root.addEventListener(
'touchstart',
(e) =>
(swipe = e.target.closest(
'.cap-bottom, .cap-seal, .cap-gate, .cap-upload, .minimap',
)
? null
: { x: e.touches[0].clientX, y: e.touches[0].clientY }),
{ passive: true },
);
root.addEventListener(
'touchend',
(e) => {
if (!swipe || mode === 'look') return (swipe = null);
const t = e.changedTouches[0];
const dx = t.clientX - swipe.x;
if (Math.abs(dx) > 70 && Math.abs(t.clientY - swipe.y) < 50) {
const i = MODES.indexOf(mode) + (dx > 0 ? -1 : 1);
setMode(MODES[Math.max(0, Math.min(MODES.length - 1, i))]);
}
swipe = null;
},
{ passive: true },
);
/* ---------- drag to look (no sensors) ---------- */
let look = null;
ar.addEventListener('pointerdown', (e) => {
if (sensors.state.hasOrientation) return;
look = { x: e.clientX, y: e.clientY, az: sim.az, el: sim.el };
ar.setPointerCapture(e.pointerId);
});
ar.addEventListener('pointermove', (e) => {
if (!look) return;
const degPerPx = VFOV / H;
sim.az = (look.az - (e.clientX - look.x) * degPerPx + 360) % 360;
sim.el = clamp(look.el + (e.clientY - look.y) * degPerPx, -30, 85);
});
ar.addEventListener('pointerup', () => (look = null));
ar.addEventListener('pointercancel', () => (look = null));
ar.style.touchAction = 'none';
function observer() {
const f = sensors.state.fix;
return f
? { lat: f.lat, lon: f.lon, alt: f.alt || 0, acc: f.acc }
: FALLBACK_POS;
}
function pose() {
return sensors.pose() || poseFromAzEl(sim.az, sim.el);
}
/* ---------- render loop ---------- */
function loop() {
if (!active || raf) return;
const frame = () => {
raf = 0;
if (!active) return;
const p = pose();
if (mode === 'look') drawLook(p);
else if (mode === 'upload') {
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, W, H);
} else drawWitness(p);
if (mode === 'live') updateLiveHud(p);
drawTape(p);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
}
const stars = (() => {
let s = 11;
const r = () => (s = (s * 16807) % 2147483647) / 2147483647;
return Array.from({ length: 260 }, () => ({
az: r() * 360,
el: Math.asin(r()) * 57.3,
m: r(),
}));
})();
function drawSky(p) {
// Horizon position at the screen centre column.
const hz = projectDir(dirFromAzEl(p.az, 0), p, W, H, VFOV);
const hy = hz.behind ? (p.el > 0 ? H * 2 : -H) : hz.y;
const g = actx.createLinearGradient(0, hy - H, 0, hy);
g.addColorStop(0, '#03060f');
g.addColorStop(0.55, '#0a1630');
g.addColorStop(0.9, '#1b3557');
g.addColorStop(1, '#3b5f80');
actx.fillStyle = g;
actx.fillRect(0, 0, W, H);
actx.fillStyle = '#05080d';
actx.save();
// Ground polygon below the horizon line, honouring roll.
const l = projectDir(dirFromAzEl(p.az - 80, 0), p, W, H, VFOV);
const r = projectDir(dirFromAzEl(p.az + 80, 0), p, W, H, VFOV);
if (!l.behind && !r.behind) {
const k = (r.y - l.y) / (r.x - l.x || 1);
const yAt = (x) => l.y + k * (x - l.x);
actx.beginPath();
actx.moveTo(-10, yAt(-10));
actx.lineTo(W + 10, yAt(W + 10));
actx.lineTo(W + 10, H + 10);
actx.lineTo(-10, H + 10);
actx.closePath();
const gg = actx.createLinearGradient(0, hy, 0, hy + H * 0.6);
gg.addColorStop(0, '#1a2633');
gg.addColorStop(1, '#04060a');
actx.fillStyle = gg;
actx.fill();
} else if (p.el < 0) actx.fillRect(0, 0, W, H);
actx.restore();
for (const s of stars) {
if (s.el < 2) continue;
const q = projectDir(dirFromAzEl(s.az, s.el), p, W, H, VFOV);
if (q.behind || q.x < 0 || q.y < 0 || q.x > W || q.y > H) continue;
actx.fillStyle = `rgba(255,255,255,${0.25 + s.m * 0.6})`;
actx.fillRect(q.x, q.y, s.m > 0.9 ? 1.8 : 1.1, s.m > 0.9 ? 1.8 : 1.1);
}
}
function drawHorizon(p) {
actx.lineWidth = 1;
for (const el of [-20, -10, 0, 10, 20, 30, 45, 60]) {
const pts = [];
for (let d = -70; d <= 70; d += 5) {
const q = projectDir(dirFromAzEl(p.az + d, el), p, W, H, VFOV);
if (!q.behind) pts.push(q);
}
if (pts.length < 2) continue;
actx.strokeStyle =
el === 0 ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.12)';
actx.setLineDash(el === 0 ? [] : [3, 6]);
actx.beginPath();
pts.forEach((q, i) =>
i ? actx.lineTo(q.x, q.y) : actx.moveTo(q.x, q.y),
);
actx.stroke();
actx.setLineDash([]);
if (el && pts.length) {
const mid = pts[Math.floor(pts.length / 2)];
actx.fillStyle = 'rgba(255,255,255,0.35)';
actx.font = '600 10px ui-rounded, system-ui';
actx.fillText(`${el > 0 ? '+' : ''}${el}°`, mid.x + 6, mid.y - 3);
}
}
// Cardinal labels on the horizon.
const card = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
actx.font = '700 13px ui-rounded, system-ui';
actx.textAlign = 'center';
for (const [n, az] of card) {
const q = projectDir(dirFromAzEl(az, 0), p, W, H, VFOV);
if (q.behind || q.x < -20 || q.x > W + 20) continue;
actx.fillStyle = n === 'N' ? '#ff6b61' : 'rgba(255,255,255,0.8)';
actx.fillText(n, q.x, q.y + 18);
}
actx.textAlign = 'left';
}
function refreshContacts() {
const now = performance.now();
if (now - contactsAt < 1000) return;
contactsAt = now;
const o = observer();
contacts = globe.contactsNear(o.lon, o.lat, 150);
}
function rrect(x, y, w, hh, r) {
actx.beginPath();
actx.moveTo(x + r, y);
actx.arcTo(x + w, y, x + w, y + hh, r);
actx.arcTo(x + w, y + hh, x, y + hh, r);
actx.arcTo(x, y + hh, x, y, r);
actx.arcTo(x, y, x + w, y, r);
actx.closePath();
}
function drawLook(p) {
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, W, H);
if (!camOk) drawSky(p);
drawHorizon(p);
refreshContacts();
const o = observer();
const items = [];
const offscreen = [];
for (const c of contacts) {
const la = lookAngles(o, c);
if (la.el < -2) continue;
const q = projectDir(la.enu, p, W, H, VFOV);
const vis = !q.behind && q.x > 0 && q.x < W && q.y > 60 && q.y < H - 40;
(vis ? items : offscreen).push({ c, la, q });
}
items.sort((a, b) => a.la.range - b.la.range);
const placed = [];
let shown = 0;
for (const it of items.slice(0, 24)) {
const { c, la, q } = it;
const col = KIND_COLOR[c.kind] || '#fff';
const near = la.range < 40e3;
// Marker
actx.strokeStyle = col;
actx.lineWidth = 1.6;
actx.beginPath();
if (c.kind === 'satellite' || c.kind === 'station') {
actx.moveTo(q.x, q.y - 7);
actx.lineTo(q.x + 7, q.y);
actx.lineTo(q.x, q.y + 7);
actx.lineTo(q.x - 7, q.y);
actx.closePath();
} else if (c.kind === 'ship' || c.kind === 'vessel') {
actx.rect(q.x - 6, q.y - 6, 12, 12);
} else actx.arc(q.x, q.y, near ? 9 : 6.5, 0, Math.PI * 2);
actx.stroke();
actx.fillStyle = col;
actx.beginPath();
actx.arc(q.x, q.y, 1.8, 0, Math.PI * 2);
actx.fill();
if (shown >= 12) continue;
// Callout with simple de-cluttering.
const title = c.title || c.id;
const sub =
c.kind === 'satellite' || c.kind === 'station'
? `${Math.round(c.altM / 1000)} km · ${fmtDist(la.range)}`
: `${c.altM > 50 ? fmtAlt(c.altM) + ' · ' : ''}${fmtDist(la.range)}`;
actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace';
const tw = Math.max(
actx.measureText(title).width,
((actx.font = '500 10.5px ui-rounded, system-ui'),
actx.measureText(sub).width),
);
const bw = tw + 18;
const bh = 36;
let bx = q.x + 16;
let by = q.y - bh - 12;
if (bx + bw > W - 8) bx = q.x - 16 - bw;
for (
let k = 0;
k < 6 &&
placed.some(
(r) =>
!(
bx + bw < r.x ||
bx > r.x + r.w ||
by + bh < r.y ||
by > r.y + r.h
),
);
k += 1
)
by -= bh + 4;
if (by < 64) continue;
placed.push({ x: bx, y: by, w: bw, h: bh });
shown += 1;
actx.strokeStyle = 'rgba(255,255,255,0.45)';
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(q.x + (bx > q.x ? 6 : -6), q.y - 6);
actx.lineTo(bx > q.x ? bx : bx + bw, by + bh);
actx.stroke();
rrect(bx, by, bw, bh, 10);
actx.fillStyle = 'rgba(8,11,17,0.72)';
actx.fill();
actx.strokeStyle = 'rgba(255,255,255,0.14)';
actx.stroke();
actx.fillStyle = col;
actx.fillRect(bx, by + 8, 2.5, bh - 16);
actx.fillStyle = '#fff';
actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace';
actx.fillText(title, bx + 10, by + 15);
actx.fillStyle = 'rgba(235,241,250,0.65)';
actx.font = '500 10.5px ui-rounded, system-ui';
actx.fillText(sub, bx + 10, by + 29);
}
// Edge arrows for the nearest contacts outside the view.
offscreen.sort((a, b) => a.la.range - b.la.range);
for (const it of offscreen.slice(0, 5)) {
const d = ((it.la.az - p.az + 540) % 360) - 180;
const x = d < 0 ? 18 : W - 18;
const y = clamp(H / 2 - (it.la.el - p.el) * (H / VFOV), 90, H - 200);
actx.fillStyle = KIND_COLOR[it.c.kind] || '#fff';
actx.globalAlpha = 0.85;
actx.beginPath();
actx.moveTo(x + (d < 0 ? -7 : 7), y);
actx.lineTo(x + (d < 0 ? 5 : -5), y - 7);
actx.lineTo(x + (d < 0 ? 5 : -5), y + 7);
actx.closePath();
actx.fill();
actx.globalAlpha = 1;
}
const counts = {};
for (const c of contacts) counts[c.kind] = (counts[c.kind] || 0) + 1;
const legend = Object.entries(counts)
.map(
([k, n]) =>
`${n} ${(KIND_LABEL[k] || [k, `${k}s`])[n > 1 ? 1 : 0]}`,
)
.join('');
const lg = $('.cap-legend', view);
const key = `${legend}|${o.simulated}|${sensors.state.hasOrientation}`;
if (lg.dataset.key !== key) {
lg.dataset.key = key;
lg.innerHTML = `${legend}${o.simulated ? 'simulated position · ' : ''}${sensors.state.hasOrientation ? 'compass-aligned' : 'drag to look'}`;
}
}
let hudT = 0;
function drawWitness(p) {
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, W, H);
if (!camOk) {
drawSky(p);
actx.fillStyle = 'rgba(255,255,255,0.5)';
actx.font = '600 12px ui-rounded, system-ui';
actx.textAlign = 'center';
actx.fillText('NO CAMERA · TEST FRAME', W / 2, H / 2 + 60);
actx.textAlign = 'left';
}
// Thirds grid
actx.strokeStyle = 'rgba(255,255,255,0.14)';
actx.lineWidth = 0.75;
actx.beginPath();
for (const k of [1 / 3, 2 / 3]) {
actx.moveTo(W * k, 0);
actx.lineTo(W * k, H);
actx.moveTo(0, H * k);
actx.lineTo(W, H * k);
}
actx.stroke();
level.style.transform = `rotate(${(-p.roll).toFixed(1)}deg)`;
level.parentElement.classList.toggle('level', Math.abs(p.roll) < 1.5);
const now = performance.now();
if (now - hudT > 100) {
hudT = now;
updateHud(p);
}
}
function updateHud(p) {
const f = sensors.state.fix;
const set = (k, v) => {
const el = view.querySelector(`[data-h="${k}"]`);
if (el && el.textContent !== v) el.textContent = v;
};
set('lat', f ? f.lat.toFixed(5) : '—');
set('lon', f ? f.lon.toFixed(5) : '—');
set(
'acc',
f ? `${Math.round(f.acc)} m` : sensors.state.geoError ? 'denied' : '…',
);
set('hdg', `${Math.round(p.az)}°`);
set('pitch', `${p.el >= 0 ? '+' : ''}${p.el.toFixed(0)}°`);
set('roll', `${p.roll >= 0 ? '+' : ''}${p.roll.toFixed(0)}°`);
const s = sensors.recording;
set(
'session',
s
? `Recording session · ${sensors.state.hasMotion ? 'IMU live' : 'no IMU'} · ${f ? 'GPS fix' : 'no GPS'}`
: 'Session idle',
);
$('.hud-rec', view).classList.toggle('on', s);
// IMU trace (sized lazily: the HUD is hidden in Look).
const hr = traceC.getBoundingClientRect();
if (Math.round(hr.width * dpr) !== traceC.width) {
traceC.width = Math.round(hr.width * dpr);
traceC.height = Math.round(hr.height * dpr);
}
const tw = traceC.width / dpr;
const th = traceC.height / dpr;
trctx.setTransform(dpr, 0, 0, dpr, 0, 0);
trctx.clearRect(0, 0, tw, th);
const tr = sensors.trace;
if (tr.length > 2) {
const vals = tr.map((x) => x.v);
const d = sparkPath(vals, tw, th, 2);
trctx.strokeStyle = '#FFD166';
trctx.lineWidth = 1.4;
trctx.stroke(new Path2D(d));
} else {
trctx.strokeStyle = 'rgba(255,255,255,0.25)';
trctx.setLineDash([2, 3]);
trctx.beginPath();
trctx.moveTo(0, th / 2);
trctx.lineTo(tw, th / 2);
trctx.stroke();
trctx.setLineDash([]);
}
}
function drawTape(p) {
const tr = tape.getBoundingClientRect();
if (
Math.round(tr.width * dpr) !== tape.width ||
Math.round(tr.height * dpr) !== tape.height
) {
tape.width = Math.round(tr.width * dpr);
tape.height = Math.round(tr.height * dpr);
}
const w = tape.width / dpr;
const hh = tape.height / dpr;
if (!w) return;
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tctx.clearRect(0, 0, w, hh);
const pxPerDeg = w / 90;
tctx.textAlign = 'center';
for (let d = Math.floor(p.az - 50); d <= p.az + 50; d += 1) {
const deg = ((d % 360) + 360) % 360;
if (deg % 5) continue;
const x = w / 2 + (d - p.az) * pxPerDeg;
const major = deg % 15 === 0;
tctx.strokeStyle = major
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.28)';
tctx.beginPath();
tctx.moveTo(x, hh - 4);
tctx.lineTo(x, hh - (major ? 12 : 7));
tctx.stroke();
if (deg % 45 === 0) {
const n = {
0: 'N',
45: 'NE',
90: 'E',
135: 'SE',
180: 'S',
225: 'SW',
270: 'W',
315: 'NW',
}[deg];
tctx.font = '700 11px ui-rounded, system-ui';
tctx.fillStyle = deg === 0 ? '#ff6b61' : '#fff';
tctx.fillText(n, x, hh - 16);
} else if (deg % 15 === 0) {
tctx.font = '500 9px ui-rounded, system-ui';
tctx.fillStyle = 'rgba(255,255,255,0.5)';
tctx.fillText(String(deg), x, hh - 16);
}
}
const fade = (x0, x1) => {
const g = tctx.createLinearGradient(x0, 0, x1, 0);
g.addColorStop(0, 'rgba(0,0,0,1)');
g.addColorStop(1, 'rgba(0,0,0,0)');
tctx.globalCompositeOperation = 'destination-out';
tctx.fillStyle = g;
tctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), hh);
tctx.globalCompositeOperation = 'source-over';
};
fade(0, 30);
fade(w, w - 30);
$('.cap-caret', view).dataset.deg = `${Math.round(p.az)}°`;
}
/* ---------- shutter ---------- */
let hold = null;
let recording = null;
shutter.addEventListener('pointerdown', (e) => {
if (mode === 'look' || mode === 'live' || mode === 'upload') return;
e.preventDefault();
hold = setTimeout(startVideo, 380);
});
const release = async () => {
if (mode === 'look' || mode === 'live' || mode === 'upload') return;
if (hold) {
clearTimeout(hold);
hold = null;
if (!recording) await takePhoto();
}
if (recording) await stopVideo();
};
shutter.addEventListener('pointerup', release);
shutter.addEventListener('pointerleave', () => recording && release());
shutter.addEventListener('click', (e) => {
if (mode === 'look') {
setMode('witness');
return;
}
if (mode === 'live') {
if (live) endLive();
else goLive();
return;
}
if (mode === 'upload') {
$('.up-btn input', view).click();
return;
}
if (e.detail === 0) takePhoto();
});
async function startVideo() {
hold = null;
if (!camOk || !camera.canRecord()) {
showStatus('Video needs a camera — sealing a still instead');
return takePhoto();
}
try {
camera.startVideo();
} catch (err) {
showStatus(err.message);
return;
}
recording = {
t0: performance.now(),
timer: setTimeout(() => stopVideo(), 15000),
};
root.classList.add('recording');
haptic('medium');
}
async function stopVideo() {
const r = recording;
recording = null;
clearTimeout(r?.timer);
root.classList.remove('recording');
const blob = await camera.stopVideo();
if (blob?.size) await sealMedia(blob, 'video');
}
async function takePhoto() {
flash();
haptic('light');
let blob;
let test = false;
try {
blob = await camera.photo();
} catch {
test = true;
blob = await new Promise((resolve) => {
const c = document.createElement('canvas');
c.width = Math.round(W * dpr);
c.height = Math.round(H * dpr);
const x = c.getContext('2d');
x.drawImage(ar, 0, 0);
x.fillStyle = 'rgba(255,209,102,0.9)';
x.font = `${Math.round(16 * dpr)}px ui-monospace, monospace`;
x.fillText(
`TEST FRAME ${new Date().toISOString()}`,
16 * dpr,
c.height - 24 * dpr,
);
c.toBlob(resolve, 'image/jpeg', 0.9);
});
}
await sealMedia(blob, 'photo', test);
}
function flash() {
const f = h('');
root.appendChild(f);
f.animate([{ opacity: 0.85 }, { opacity: 0 }], {
duration: reducedMotion() ? 80 : 320,
easing: 'ease-out',
}).finished.then(() => f.remove());
}
async function sealMedia(blob, kind, test = false, preset = null) {
const session = preset ||
sensors.endSession() || {
start: new Date().toISOString(),
end: new Date().toISOString(),
imu: [],
gps: [],
heading: null,
};
if (!session.heading) {
const p = pose();
session.heading = {
deg: Math.round(p.az * 10) / 10,
pitch: Math.round(p.el * 10) / 10,
roll: 0,
source: 'simulated',
};
}
session.mode = kind;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const context = {
app: 'gods-eye-mobile-prototype/0.1',
device:
navigator.userAgentData?.platform || navigator.platform || 'unknown',
mobile:
navigator.userAgentData?.mobile ?? /Mobi/i.test(navigator.userAgent),
ua: navigator.userAgent,
screen: {
w: screen.width,
h: screen.height,
dpr: devicePixelRatio,
orientation: screen.orientation?.type || null,
},
timezone: tz,
tzOffsetMin: new Date().getTimezoneOffset(),
networkTimeOffsetMs: netOffset,
camera: camera.info(),
sensors: {
motion: sensors.state.hasMotion,
orientation: sensors.state.hasOrientation,
absoluteHeading:
sensors.state.absolute || sensors.state.compass != null,
},
testFrame: test || undefined,
};
const url = URL.createObjectURL(blob);
showSealing(url, kind, test);
try {
const bundle = await seal({ media: blob, session, context });
lastCapture = { bundle, url };
setThumb(url, kind === 'live' ? 'video' : kind);
showSealed(bundle, url, kind, test);
onSealed?.(bundle);
} catch (err) {
console.error(err);
sealEl.hidden = true;
toast(`Could not seal: ${err.message}`);
}
if (active && mode === 'witness') sensors.beginSession();
}
function setThumb(url, kind) {
const t = $('.cap-thumb', view);
t.innerHTML =
kind === 'video'
? ``
: ``;
t.classList.add('has');
}
/* ---------- seal ceremony ---------- */
const SEAL_D = (() => {
let d = '';
for (let i = 0; i <= 96; i += 1) {
const a = (i / 96) * Math.PI * 2;
const r = 40 + 4.2 * Math.cos(a * 12);
d += `${i ? 'L' : 'M'}${(50 + r * Math.cos(a)).toFixed(2)} ${(50 + r * Math.sin(a)).toFixed(2)}`;
}
return `${d}Z`;
})();
function showSealing(url, kind, test) {
sealEl.hidden = false;
sealEl.innerHTML = `
${icon('pin')}Place it — drag the map under the pin
${icon('clock')}When was it taken
${icon('info')}Marked Unverified everywhere. EXIF is easy to edit and nothing was sealed when this was taken, so it can be cited by a hypothesis but never counted as an observation or earn Was there.