// Camera stream sheet: the phone camera fills whole boards, live, peer to peer (WebRTC). // The camera follows the Message panel's "Send to" (Jay, Oct 25 10:53 PM): // Everyone -> one tap streams to EVERY board I may message (fan-out: one RTCPeerConnection per board, all // fed by the one camera; opted-out boards never come back from /boards, offline / busy ones are // skipped). The sheet says "Streaming to N boards" (only boards actually live) + a status row each. // Multiple users -> the same fan-out, limited to the selected users' boards. // One user -> #609, unchanged (below). // Opened from the camera button in the message composer. When the composer already has ONE user picked (`target`), // the sheet uses that user: one ready board -> straight to it, 2+ -> a picker of only their boards, none -> why // (Jay, Oct 25: "select user then hit camera"). No pre-picked user -> the full picker, exactly as before. 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-why{display:grid;gap:6px;padding:12px;border-radius:12px;border:2px solid #f59e0b;background:#2a2108;color:var(--cs-fg);font-size:16px;line-height:1.4} .cs-why p{margin:0} .cs-link{background:none;border:0;padding:6px 0;color:#93c5fd;font-size:15px;font-weight:700;text-decoration:underline;cursor:pointer;justify-self:start;min-height:44px} .cs-hidden{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none} .cs-status{cursor:default} .cs-status>span:first-child{min-width:0;overflow-wrap:anywhere} .cs-status .cs-tag{flex-shrink:0} .cs-tag.wait{background:#1e3a8a;color:#dbeafe} .cs-tag.skip{background:#3f3f46;color:#e4e4e7} .cs-tag.bad{background:#7f1d1d;color:#fecaca} `; 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 getCamera(facing) { try { if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) throw Object.assign(new Error("x"), { name: "TypeError" }); return navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); } catch (e) { return Promise.reject(e); } } const dropCamera = (p) => { if (p) p.then((m) => { try { m.getTracks().forEach((t) => t.stop()); } catch (e) { /* gone */ } }, () => {}); }; function fanTag(r) { if (r.state === "live") return { label: "Live", cls: "ok" }; if (r.state === "connecting") return { label: "Connecting…", cls: "wait" }; if (r.state === "reconnecting") return { label: "Reconnecting…", cls: "wait" }; if (r.state === "skipped") return { label: `Skipped · ${r.note || "Unavailable"}`, cls: "skip" }; if (r.state === "error") return { label: r.note || "Lost connection", cls: "bad" }; return { label: r.note ? `Ended · ${r.note}` : "Ended", cls: "skip" }; } // fanout: null (One user / no user: #609), "everyone", or an array of handles (Multiple users) function CameraStreamSheet({ open, onClose, apiBase, token, withCode, target, me, fanout }) { 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 [showAll, setShowAll] = useState(false); // "Show all boards": leave the picked user's view const [plan, setPlan] = useState(null); // CS.planForTarget for the picked user (null while loading) const fanMode = !!fanout && (fanout === "everyone" || (Array.isArray(fanout) && fanout.length > 0)) && !showAll; const fanUsers = Array.isArray(fanout) ? fanout : null; const [fanRows, setFanRows] = useState([]); // FanoutController rows (one per board, incl. skipped) const [fanWhy, setFanWhy] = useState(null); // nothing ready: plain reasons const fanSrcRef = useRef(new Map()); // device id -> {canvas, g, cur, cap} for the shared draw loop const who = fanMode ? "" : String(target || "").trim(); const targeted = !!who && !showAll; const self = CS ? CS.sameHandle(who, me) : false; const preCamRef = useRef(null), startRef = useRef(null), fanStartRef = useRef(null), showAllRef = useRef(false); showAllRef.current = showAll; 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(); const list = Array.isArray(r.boards) ? r.boards : []; setBoards({ loading: false, list, error: "" }); if (r && r.max_s != null) setMaxS(CS.sessionSeconds(r.max_s)); // the server's limit, if /boards gives it return list; } catch (e) { setBoards({ loading: false, list: [], error: e.message || CS.NETWORK_MSG }); return null; } }, [api]); // load boards, then (for a picked user) decide: start now / their picker / why not. Auto-start only from a tap. const resolve = useCallback(async (autoStart) => { setPlan(null); setFanWhy(null); const list = await loadBoards(); const pre = preCamRef.current; preCamRef.current = null; if (fanMode && !showAllRef.current && list !== null) { const fp = CS.planForEveryone(list, fanUsers, me); if (!fp.ready.length) { dropCamera(pre); setFanWhy(fp.reasons); return; } if (autoStart && fanStartRef.current) fanStartRef.current(fp, pre); else dropCamera(pre); return; } if (!who || showAllRef.current || list === null) { dropCamera(pre); return; } const p = CS.planForTarget(list, who, me); setPlan(p); if (p.mode === "auto" && autoStart && startRef.current) startRef.current(p.board, pre); else dropCamera(pre); }, [loadBoards, who, me, fanMode, fanUsers]); const releaseCamera = useCallback(() => { if (drawRef.current) { clearInterval(drawRef.current); drawRef.current = null; } for (const src of fanSrcRef.current.values()) { try { src.cap && src.cap.getTracks().forEach((t) => t.stop()); } catch (e) { /* gone */ } } fanSrcRef.current.clear(); 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); setShowAll(false); showAllRef.current = false; setPlan(null); setFanRows([]); setFanWhy(null); try { const stale = sessionStorage.getItem(SS_KEY); if (stale) { sessionStorage.removeItem(SS_KEY); stale.split(",").filter(Boolean).forEach((sid) => api().end(sid, "stale_tab").catch(() => {})); } } catch (e) { /* private mode */ } // a picked user: ask for the camera NOW, inside the Camera tap (iOS), while the boards load; dropped unless used if (who || fanMode) preCamRef.current = getCamera(facing); resolve(true); return () => { dropCamera(preCamRef.current); preCamRef.current = null; 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, preCam) => { if (!b || !CS) return; setBoard(b); setError(""); setEndNote(""); setPhase("connecting"); const camPromise = preCam || getCamera(facing); (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); })(); }; startRef.current = start; // Everyone / Multiple users: ONE camera, one canvas crop per board (each board gets exactly its own size), one // draw loop, one RTCPeerConnection per board (FanoutController). The biggest board's canvas is the preview. const startFanout = (fp, preCam) => { if (!CS || !fp || !fp.ready.length) return; setError(""); setEndNote(""); setFanWhy(null); setBoard(null); setPhase("connecting"); const camPromise = preCam || getCamera(facing); const skippedRows = fp.skipped.map(({ board: b, reason }) => ({ device_id: b.device_id, name: b.name || `Board ${b.device_id}`, mine: !!b.mine, owner_handle: b.owner_handle || null, video: CS.videoSize(b.video), state: "skipped", note: reason })); (async () => { let media; try { media = await camPromise; } catch (e) { const code = CS.cameraErrorCode(e); setError(CS.errorMessage(code)); setPhase("error"); return; } attachCamera(media); const area = (b) => { const v = CS.videoSize(b.video); return v ? v.width * v.height : 0; }; const previewId = fp.ready.slice().sort((a, b) => area(b) - area(a))[0].device_id; const srcs = fanSrcRef.current; const paint = (src) => { const v = camRef.current; if (!v || v.readyState < 2) return; const r = CS.coverRect(v.videoWidth, v.videoHeight, src.cur.width, src.cur.height); if (r) src.g.drawImage(v, r.sx, r.sy, r.sw, r.sh, 0, 0, src.cur.width, src.cur.height); }; const sizeCanvas = (src, vs) => { src.cur = vs; src.canvas.width = vs.width; src.canvas.height = vs.height; src.g.imageSmoothingEnabled = true; src.g.imageSmoothingQuality = "high"; src.g.fillStyle = "#000"; src.g.fillRect(0, 0, vs.width, vs.height); }; let fps = 15; const t0 = Date.now(); let lim = maxS; const sids = []; let wasLive = false; const fan = new CS.FanoutController({ api: api(), boards: fp.ready, source: (b, vs) => { const canvas = b.device_id === previewId ? canvasRef.current : document.createElement("canvas"); const src = { canvas, g: canvas.getContext("2d", { alpha: false }), cur: vs, cap: null }; sizeCanvas(src, vs); if (b.device_id === previewId) setVideo(vs); fps = Math.max(fps, vs.fps); src.cap = canvas.captureStream(vs.fps); srcs.set(b.device_id, src); paint(src); return { track: src.cap.getVideoTracks()[0], stream: src.cap, resize: (nv) => { const cur = { ...nv, fps: Math.min(nv.fps, vs.fps) }; if (cur.width === src.cur.width && cur.height === src.cur.height) return; sizeCanvas(src, cur); if (b.device_id === previewId) setVideo(cur); }, release: () => { try { src.cap.getTracks().forEach((t) => t.stop()); } catch (e) { /* gone */ } srcs.delete(b.device_id); }, }; }, onSession: (sess) => { sids.push(sess.session_id); try { sessionStorage.setItem(SS_KEY, sids.join(",")); } catch (e) { /* private mode */ } const l = CS.sessionSeconds(sess.max_s != null ? sess.max_s : sess.hard_cap_s); if (l < lim || sids.length === 1) { lim = l; setMaxS(l); setHardAt(t0 + l * 1000); } }, onUpdate: (rows) => { setFanRows([...rows, ...skippedRows]); if (ctlRef.current !== fan) return; const live = rows.filter((r) => r.state === "live").length; const busy = rows.filter((r) => r.state === "connecting" || r.state === "reconnecting").length; if (live > 0) { wasLive = true; setLiveAt((x) => x || Date.now()); setPhase("live"); } else if (busy > 0) setPhase((p) => (p === "live" || p === "reconnecting" ? "reconnecting" : "connecting")); else { // every board ended / failed / was skipped ctlRef.current = null; releaseCamera(); try { sessionStorage.removeItem(SS_KEY); } catch (e) { /* private mode */ } if (phaseRef.current !== "ended") { setEndNote(wasLive ? "" : "None of the boards could show your camera."); setPhase("ended"); } } }, }); setFanRows([...fan.rows(), ...skippedRows]); ctlRef.current = fan; setLiveAt(0); setHardAt(t0 + lim * 1000); setDeadline(t0 + KEEP_MS); drawRef.current = setInterval(() => { for (const src of srcs.values()) paint(src); }, Math.round(1000 / fps)); fan.start(); })(); }; fanStartRef.current = startFanout; const again = () => { // a tap: may auto-start, so ask for the camera inside it (iOS) setPhase("pick"); setError(""); setEndNote(""); setFanRows([]); if ((who && !showAllRef.current) || (fanMode && !showAllRef.current)) { dropCamera(preCamRef.current); preCamRef.current = getCamera(facing); } resolve(true); }; const showAllBoards = () => { showAllRef.current = true; setShowAll(true); setPlan(null); if (!boards.list.length) loadBoards(); }; 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 setFacingLive = async (next) => { // Back / Front while fanning out: swap the camera, same tracks if (next === facing) return; 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); 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 facingSeg = (
{t}
)}{t}
)}