|
5 | 5 | * All Snapmaker camera logic lives here — inventory.js calls this and |
6 | 6 | * never builds camera HTML inline. |
7 | 7 | * |
8 | | - * Stream: Crowsnest WebRTC player page (port 80, path /webcam/webrtc). |
9 | | - * The iframe handles the full WebRTC negotiation internally — no |
10 | | - * RTCPeerConnection in the renderer. |
| 8 | + * Two feeds, picked per firmware (`conn.camMode`, see index.js): |
| 9 | + * • "webrtc" (Paxx extended firmware) — Crowsnest WebRTC player page (port 80, |
| 10 | + * path /webcam/webrtc). The iframe handles the full WebRTC negotiation |
| 11 | + * internally — no RTCPeerConnection in the renderer. |
| 12 | + * • "still" (stock firmware) — there is no stream, but the printer's own |
| 13 | + * service refreshes a 1080p JPEG (~1 fps) while it is woken up every few |
| 14 | + * seconds. An <img> is fed frame by frame; see the still-feed loop below. |
11 | 15 | */ |
12 | 16 | import { ctx } from '../context.js'; |
13 | | -import { snapGetConn, snapKey } from './index.js'; |
| 17 | +import { snapGetConn, snapKey, snapWakeStockCamera } from './index.js'; |
| 18 | + |
| 19 | +const LOADING_OVERLAY = ` |
| 20 | + <div class="pp-cam-loading-overlay"> |
| 21 | + <span class="pp-cam-loading-dots"> |
| 22 | + <span></span><span></span><span></span> |
| 23 | + </span> |
| 24 | + </div>`; |
14 | 25 |
|
15 | 26 | /** |
16 | 27 | * Returns the full camera banner HTML for a Snapmaker printer, |
17 | | - * or "" when the printer is offline / not yet connected. |
| 28 | + * or "" when the printer is offline / not yet connected / has no camera. |
18 | 29 | * |
19 | 30 | * @param {object} p — printer record from state.printers |
20 | 31 | * @returns {string} — HTML string (safe to assign to innerHTML) |
21 | 32 | */ |
22 | 33 | export function renderSnapCamBanner(p) { |
23 | | - const conn = snapGetConn(snapKey(p)); |
| 34 | + const key = snapKey(p); |
| 35 | + const conn = snapGetConn(key); |
24 | 36 | if (!conn || conn.status !== "connected" || !conn.ip) return ""; |
| 37 | + if (conn.camMode === "none") return ""; |
| 38 | + // Mode not known yet (the webcams query is in flight): hold the frame with |
| 39 | + // the loading dots rather than load a player that may not exist — the |
| 40 | + // answer triggers a re-render. |
| 41 | + if (!conn.camMode) return `<div class="pp-cam-full pp-cam-loading">${LOADING_OVERLAY}</div>`; |
| 42 | + if (conn.camMode === "still") { |
| 43 | + _stillEnsure(key); |
| 44 | + return ` |
| 45 | + <div class="pp-cam-full pp-cam-loading"> |
| 46 | + <img class="snap-camera-frame snap-camera-still" data-snap-cam="${ctx.esc(key)}" alt="" draggable="false"> |
| 47 | + ${LOADING_OVERLAY} |
| 48 | + </div>`; |
| 49 | + } |
25 | 50 | return ` |
26 | 51 | <div class="pp-cam-full pp-cam-loading"> |
27 | 52 | <iframe class="snap-camera-frame" src="${ctx.esc(`http://${conn.ip}/webcam/webrtc`)}" |
28 | 53 | sandbox="allow-scripts allow-same-origin" |
29 | 54 | loading="lazy" referrerpolicy="no-referrer" |
30 | 55 | allow="autoplay" |
31 | 56 | onload="var h=this.closest('.pp-cam-loading');if(h){h.classList.remove('pp-cam-loading');h.querySelector('.pp-cam-loading-overlay')?.remove();}"></iframe> |
32 | | - <div class="pp-cam-loading-overlay"> |
33 | | - <span class="pp-cam-loading-dots"> |
34 | | - <span></span><span></span><span></span> |
35 | | - </span> |
36 | | - </div> |
| 57 | + ${LOADING_OVERLAY} |
37 | 58 | </div>`; |
38 | 59 | } |
| 60 | + |
| 61 | +/* ── Still feed (stock firmware) ───────────────────────────────────────────── |
| 62 | + One loop per printer, shared by every surface showing it (side panel, camera |
| 63 | + wall, printer card): each tick finds the <img data-snap-cam="key"> currently |
| 64 | + in the document and hands them the same frame. |
| 65 | +
|
| 66 | + • Wake every WAKE_MS — the printer stops refreshing the JPEG once nobody asks. |
| 67 | + • A frame every FRAME_MS, fetched with no-store into a Blob URL: the image is |
| 68 | + swapped only once fully downloaded (no blank between frames), and the |
| 69 | + previous Blob URL is revoked, so an hour of watching does not pile up |
| 70 | + ~100 KB frames in the HTTP cache. |
| 71 | + • Nobody on screen for IDLE_STOP_MS → the loop stops, and so do the wake-ups: |
| 72 | + the camera goes back to idle on its own. Same grace idea as cam_manager.js, |
| 73 | + so a view switch does not restart the feed from nothing. */ |
| 74 | +const FRAME_MS = 1000; |
| 75 | +const WAKE_MS = 10000; |
| 76 | +const IDLE_STOP_MS = 12000; |
| 77 | +const _stills = new Map(); // key → { timer, lastWake, idleSince, busy, blobUrl } |
| 78 | + |
| 79 | +function _stillImgs(key) { |
| 80 | + return document.querySelectorAll(`img.snap-camera-still[data-snap-cam="${CSS.escape(key)}"]`); |
| 81 | +} |
| 82 | + |
| 83 | +function _stillEnsure(key) { |
| 84 | + if (_stills.has(key)) return; |
| 85 | + const s = { timer: null, lastWake: 0, idleSince: 0, busy: false, blobUrl: null }; |
| 86 | + _stills.set(key, s); |
| 87 | + s.timer = setInterval(() => _stillTick(key, s), FRAME_MS); |
| 88 | + // First tick after the banner HTML has landed in the DOM. |
| 89 | + setTimeout(() => _stillTick(key, s), 0); |
| 90 | +} |
| 91 | + |
| 92 | +function _stillStop(key) { |
| 93 | + const s = _stills.get(key); |
| 94 | + if (!s) return; |
| 95 | + clearInterval(s.timer); |
| 96 | + if (s.blobUrl) URL.revokeObjectURL(s.blobUrl); |
| 97 | + _stills.delete(key); |
| 98 | +} |
| 99 | + |
| 100 | +async function _stillTick(key, s) { |
| 101 | + const conn = snapGetConn(key); |
| 102 | + if (!conn || conn.status !== "connected" || conn.camMode !== "still") { _stillStop(key); return; } |
| 103 | + const imgs = _stillImgs(key); |
| 104 | + const now = Date.now(); |
| 105 | + if (!imgs.length) { |
| 106 | + if (!s.idleSince) s.idleSince = now; |
| 107 | + else if (now - s.idleSince > IDLE_STOP_MS) _stillStop(key); |
| 108 | + return; |
| 109 | + } |
| 110 | + s.idleSince = 0; |
| 111 | + if (now - s.lastWake >= WAKE_MS && snapWakeStockCamera(conn)) s.lastWake = now; |
| 112 | + if (s.busy) return; // a slow frame is still downloading — skip, never queue |
| 113 | + s.busy = true; |
| 114 | + try { |
| 115 | + const res = await fetch(`http://${conn.ip}:7125/server/files/camera/monitor.jpg`, { cache: "no-store" }); |
| 116 | + if (!res.ok) return; |
| 117 | + const url = URL.createObjectURL(await res.blob()); |
| 118 | + if (!_stills.has(key)) { URL.revokeObjectURL(url); return; } // stopped meanwhile |
| 119 | + const prev = s.blobUrl; |
| 120 | + s.blobUrl = url; |
| 121 | + _stillImgs(key).forEach(img => { |
| 122 | + img.src = url; |
| 123 | + const host = img.closest(".pp-cam-loading"); |
| 124 | + if (host) { host.classList.remove("pp-cam-loading"); host.querySelector(".pp-cam-loading-overlay")?.remove(); } |
| 125 | + }); |
| 126 | + // Revoke once the new frame is painted, not before (the old URL may still |
| 127 | + // be the one on screen for a few ms). |
| 128 | + if (prev) setTimeout(() => URL.revokeObjectURL(prev), 2000); |
| 129 | + } catch (_) { |
| 130 | + // Printer busy / network blip — the next tick simply tries again. |
| 131 | + } finally { |
| 132 | + s.busy = false; |
| 133 | + } |
| 134 | +} |
0 commit comments