// Camera stream sheet: pick a board, then the phone camera fills that whole board, live, peer to peer (WebRTC). // Opened from the camera button in the message composer. Signaling + rules live in utils/camera-stream.js and the // API (blackbook-api stream.py); this file is only the UI. The picture size always comes from the API's `video` // for the chosen board (center-cropped to fill it); there is no layout / crop / letterbox choice on purpose. (function () { const { useState, useEffect, useRef, useCallback } = React; const CS = window.BBCameraStream; const CS_CSS = ` .cs-wrap{position:fixed;inset:0;z-index:70;display:flex;align-items:flex-end;justify-content:center} @media (min-width:640px){.cs-wrap{align-items:center;padding:24px}} .cs-back{position:absolute;inset:0;background:rgba(0,0,0,.7);border:0} .cs{--cs-bg:#0b0d12;--cs-card:#151923;--cs-line:#2a3142;--cs-fg:#f4f6fb;--cs-mute:#a9b3c7;--cs-ok:#22c55e;--cs-bad:#f87171;--cs-live:#dc2626; position:relative;width:100%;max-width:480px;max-height:100%;overflow-y:auto;overflow-x:hidden;color:var(--cs-fg);background:var(--cs-bg); border:1px solid var(--cs-line);border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom)); font:16px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:grid;grid-template-columns:minmax(0,1fr);gap:12px;box-sizing:border-box} @media (min-width:640px){.cs{border-radius:18px}} .cs *{box-sizing:border-box;min-width:0} .cs-head{display:flex;align-items:center;justify-content:space-between;gap:10px} .cs-h{margin:0;font-size:19px;font-weight:800} .cs-sub{color:var(--cs-mute);font-size:14px} .cs-btn{min-height:48px;padding:10px 14px;border-radius:12px;border:2px solid var(--cs-line);background:#222838;color:var(--cs-fg);font-size:16px;font-weight:700;cursor:pointer;touch-action:manipulation} .cs-btn:active{transform:scale(.98)}.cs-btn[disabled]{opacity:.45;cursor:not-allowed} .cs-btn.sm{min-height:44px;padding:6px 12px;font-size:15px} .cs-btn.stop{background:#3b0d0d;border-color:#b91c1c;color:#fecaca;width:100%;min-height:56px;font-size:19px} .cs-btn.go{background:#16a34a;border-color:#22c55e;color:#fff} .cs-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .cs-row>.cs-btn{flex:1 1 0} .cs-list{display:grid;gap:8px} .cs-board{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;width:100%;text-align:left;min-height:60px;padding:10px 12px;border-radius:12px;border:2px solid var(--cs-line);background:#1b2030;color:var(--cs-fg);cursor:pointer} .cs-board[disabled]{opacity:.5;cursor:not-allowed} .cs-board:not([disabled]):active{transform:scale(.99)} .cs-name{font-weight:800;font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cs-tag{font-size:13px;font-weight:800;padding:4px 10px;border-radius:999px;background:#25304a;white-space:nowrap} .cs-tag.ok{background:var(--cs-ok);color:#04130a} .cs-seg{display:grid;grid-template-columns:1fr 1fr;gap:8px} .cs-seg .cs-btn.on{background:#1d4ed8;border-color:#60a5fa;color:#fff} .cs-stage{position:relative;border-radius:12px;overflow:hidden;background:#000;border:2px solid var(--cs-line)} .cs-canvas{display:block;width:100%;height:auto;image-rendering:pixelated;image-rendering:crisp-edges} .cs-live{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-weight:900;font-size:20px;letter-spacing:.04em;background:rgba(0,0,0,.65)} .cs-live.on{background:var(--cs-live);color:#fff} .cs-dot{width:12px;height:12px;border-radius:50%;background:#fff} .cs-live.on .cs-dot{animation:cs-blink 1s steps(2,start) infinite} @keyframes cs-blink{to{visibility:hidden}} .cs-timer{font-variant-numeric:tabular-nums} .cs-err{color:#fff;background:#7f1d1d;border:2px solid var(--cs-bad);border-radius:12px;padding:12px;font-size:16px;font-weight:600} .cs-keep{display:grid;gap:8px;padding:12px;border-radius:12px;border:2px solid #fbbf24;background:#2a2108} .cs-hidden{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none} `; const KEEP_MS = 5 * 60 * 1000; // auto-stop after 5 min unless "Keep going" const PROMPT_MS = 30 * 1000; // ask this long before the auto-stop const SS_KEY = "bb_stream_session"; // a session left open by a closed tab: ended on next open function boardStatus(b) { if (b.busy) return { label: "Busy", ok: false }; if (!b.online) return { label: "Offline", ok: false }; if (!b.stream_ok) return { label: "Needs update", ok: false }; return { label: "Ready", ok: true }; } function CameraStreamSheet({ open, onClose, apiBase, token, withCode }) { const [phase, setPhase] = useState("pick"); // pick | connecting | live | reconnecting | ended | error const [boards, setBoards] = useState({ loading: true, list: [], error: "" }); const [facing, setFacing] = useState("environment"); const [board, setBoard] = useState(null); const [error, setError] = useState(""); const [endNote, setEndNote] = useState(""); const [video, setVideo] = useState(null); const [now, setNow] = useState(Date.now()); const [liveAt, setLiveAt] = useState(0); const [deadline, setDeadline] = useState(0); const [hardAt, setHardAt] = useState(0); const [maxS, setMaxS] = useState(300); // session auto-stop from the server (hard_cap_s) const canvasRef = useRef(null), camRef = useRef(null), ctlRef = useRef(null), mediaRef = useRef(null); const drawRef = useRef(null), capRef = useRef(null), phaseRef = useRef(phase); phaseRef.current = phase; const api = useCallback(() => CS.makeApi({ apiBase, token, withCode }), [apiBase, token, withCode]); const loadBoards = useCallback(async () => { setBoards((b) => ({ ...b, loading: true, error: "" })); try { const r = await api().boards(); setBoards({ loading: false, list: Array.isArray(r.boards) ? r.boards : [], error: "" }); if (r && r.max_s != null) setMaxS(CS.sessionSeconds(r.max_s)); // the server's limit, if /boards gives it } catch (e) { setBoards({ loading: false, list: [], error: e.message || CS.NETWORK_MSG }); } }, [api]); const releaseCamera = useCallback(() => { if (drawRef.current) { clearInterval(drawRef.current); drawRef.current = null; } for (const s of [mediaRef.current, capRef.current]) { try { s && s.getTracks().forEach((t) => t.stop()); } catch (e) { /* gone */ } } mediaRef.current = null; capRef.current = null; if (camRef.current) camRef.current.srcObject = null; }, []); const finish = useCallback((reason) => { const ctl = ctlRef.current; ctlRef.current = null; if (ctl) ctl.stop(reason); releaseCamera(); try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* private mode */ } }, [releaseCamera]); // open: fresh picker; end a session a previous tab left behind useEffect(() => { if (!open) return undefined; setPhase("pick"); setError(""); setEndNote(""); setBoard(null); try { const stale = sessionStorage.getItem(SS_KEY); if (stale) { sessionStorage.removeItem(SS_KEY); api().end(stale, "stale_tab").catch(() => {}); } } catch (e) { /* private mode */ } loadBoards(); return () => finish("closed"); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps // iOS Safari: a hidden page loses the camera anyway; stop cleanly so the board isn't left on a frozen frame useEffect(() => { if (!open) return undefined; const onHide = () => { if (document.visibilityState === "hidden" && ctlRef.current) { finish("page_hidden"); setPhase("ended"); setEndNote("Stopped because the app went to the background."); } }; const onPageHide = () => { if (ctlRef.current) finish("page_hidden"); }; document.addEventListener("visibilitychange", onHide); window.addEventListener("pagehide", onPageHide); return () => { document.removeEventListener("visibilitychange", onHide); window.removeEventListener("pagehide", onPageHide); }; }, [open, finish]); // 1 s clock for the LIVE timer and the server's session limit (max_s, default 5 min) useEffect(() => { if (!(phase === "live" || phase === "reconnecting" || phase === "connecting")) return undefined; const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [phase]); useEffect(() => { if (!ctlRef.current || !(phase === "live" || phase === "reconnecting")) return; if (hardAt && now >= hardAt) { finish("hard_cap"); setPhase("ended"); setEndNote(CS.endNote("hard_cap", maxS)); } else if (deadline && now >= deadline) { finish("auto_stop"); setPhase("ended"); setEndNote(CS.endNote("time_limit", Math.min(maxS, KEEP_MS / 1000))); } }, [now, deadline, hardAt, maxS, phase, finish]); const attachCamera = (media) => { mediaRef.current = media; const v = camRef.current; v.srcObject = media; v.muted = true; v.setAttribute("playsinline", ""); v.setAttribute("muted", ""); const p = v.play(); if (p && p.catch) p.catch(() => {}); }; // the tap: camera permission FIRST (iOS needs it inside the gesture), then the session const start = (b) => { if (!b || !CS) return; setBoard(b); setError(""); setEndNote(""); setPhase("connecting"); let camPromise; try { if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) throw Object.assign(new Error("x"), { name: "TypeError" }); camPromise = navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); } catch (e) { camPromise = Promise.reject(e); } (async () => { let media; try { media = await camPromise; } catch (e) { const code = CS.cameraErrorCode(e); setError(CS.errorMessage(code)); setPhase("error"); return; } attachCamera(media); let sess; try { sess = await api().create(b.device_id); } catch (e) { releaseCamera(); setError(e.message || CS.NETWORK_MSG); setPhase("error"); return; } const vs = CS.videoSize(sess.video); if (!vs) { api().end(sess.session_id, "bad_video").catch(() => {}); releaseCamera(); setError(CS.NETWORK_MSG); setPhase("error"); return; } try { sessionStorage.setItem(SS_KEY, sess.session_id); } catch (e) { /* private mode */ } setVideo(vs); const cv = canvasRef.current; cv.width = vs.width; cv.height = vs.height; const g = cv.getContext("2d", { alpha: false }); g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high"; let cur = vs; // the board may report its real canvas in the answer: then we resize to it (same track) const draw = () => { const v = camRef.current; if (!v || v.readyState < 2) return; const r = CS.coverRect(v.videoWidth, v.videoHeight, cur.width, cur.height); if (r) g.drawImage(v, r.sx, r.sy, r.sw, r.sh, 0, 0, cur.width, cur.height); }; g.fillStyle = "#000"; g.fillRect(0, 0, vs.width, vs.height); drawRef.current = setInterval(draw, Math.round(1000 / vs.fps)); const cap = cv.captureStream(vs.fps); capRef.current = cap; const t0 = Date.now(); const lim = CS.sessionSeconds(sess.max_s != null ? sess.max_s : sess.hard_cap_s); setMaxS(lim); setHardAt(t0 + lim * 1000); // the server's STREAM_SESSION_MAX_S (default 300) setDeadline(t0 + KEEP_MS); const ctl = new CS.StreamController({ api: api(), onVideo: (nv) => { if (ctlRef.current !== ctl || (nv.width === cur.width && nv.height === cur.height)) return; cur = { ...nv, fps: Math.min(nv.fps, vs.fps) }; cv.width = cur.width; cv.height = cur.height; g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high"; g.fillStyle = "#000"; g.fillRect(0, 0, cur.width, cur.height); setVideo(cur); }, onState: (st) => { if (ctlRef.current !== ctl) return; if (st.state === "live") { setLiveAt((x) => x || Date.now()); setPhase("live"); } else if (st.state === "reconnecting") setPhase("reconnecting"); else if (st.state === "connecting") setPhase("connecting"); else if (st.state === "error") { ctlRef.current = null; releaseCamera(); setError(st.error || CS.NETWORK_MSG); setPhase("error"); } else if (st.state === "ended") { ctlRef.current = null; releaseCamera(); try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* private mode */ } if (phaseRef.current !== "ended") { setEndNote(CS.endNote(st.reason, maxS)); setPhase("ended"); } } }, }); ctlRef.current = ctl; setLiveAt(0); ctl.start(sess, cap.getVideoTracks()[0], cap); })(); }; const stop = () => { finish("user_stop"); setPhase("ended"); setEndNote(""); }; const keepGoing = () => { const next = Math.min(Date.now() + KEEP_MS, hardAt || Infinity); setDeadline(next); if (ctlRef.current) ctlRef.current.heartbeat().catch(() => {}); }; const flip = async () => { const next = facing === "environment" ? "user" : "environment"; setFacing(next); if (!mediaRef.current) return; try { const media = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: next }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); const old = mediaRef.current; attachCamera(media); // the canvas track the board receives stays the same: no renegotiation try { old.getTracks().forEach((t) => t.stop()); } catch (e) { /* gone */ } } catch (e) { /* keep the current camera */ } }; const close = () => { finish("closed"); onClose && onClose(); }; if (!open) return null; const streaming = phase === "connecting" || phase === "live" || phase === "reconnecting"; const elapsed = liveAt ? now - liveAt : 0; const showKeep = phase === "live" && deadline && deadline - now <= PROMPT_MS && (!hardAt || deadline < hardAt); const badge = phase === "live" ? "LIVE" : phase === "reconnecting" ? "Reconnecting…" : "Connecting…"; return (
}
{/* always mounted: the hidden camera feeds the canvas, the canvas is what the board gets */}