The shop screen no longer shows camera video

The Live screen led with a camera tile beside the arrivals. Nobody at a
counter is watching CCTV; they are looking up at a customer and need the
name. The tile also cost CPU the recognition pipeline needs and pulled a
stream relay into the app for a picture that was decoration. Arrivals now
take the whole screen. The camera picture stays on the Cameras screen,
where it is a setup tool and not a feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-18 10:53:32 +05:30
parent 3d3775c8be
commit 979aa77cda
5 changed files with 38 additions and 91 deletions

View File

@@ -469,3 +469,8 @@ tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2);
.live-split { grid-template-columns: minmax(0, 1fr); }
.live-split > .panel { max-height: none; }
}
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
inside the panel rather than pushing the metrics off the bottom. */
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }

View File

@@ -11,13 +11,14 @@ import * as Icon from '../ui/icons.jsx'
// actionable content taking the most space, and the product's whole reason for
// existing rendered as a log.
//
// Now: a status strip that answers "is this working" in one line, cameras as
// pictures, and arrivals as cards big enough to recognise a customer from
// while looking up at them.
// Now: a status strip that answers "is this working" in one line, and arrivals
// as cards big enough to recognise a customer from while looking up at them.
// No camera picture here - the person at the counter is not watching CCTV,
// and a live video tile costs CPU the recognition pipeline needs. The picture
// lives on the Cameras screen, where it is a setup tool.
export default function Live() {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
const cams = useCameraFeeds()
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
@@ -39,65 +40,30 @@ export default function Live() {
<div className="page">
<header>
<h2>Live</h2>
<p>Who is in the shop, what the cameras see, and whether any of it is reaching head office.</p>
<p>Who is in the shop, and whether it is reaching head office.</p>
</header>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
<div className="live-split">
<div className="panel">
<div className="panelhead">
<h3>Cameras</h3>
{cameras.length > 0 && (
<span className="pill">{up} of {cameras.length} live</span>
)}
</div>
<div className="panelbody flush">
{cameras.length === 0
? <div className="empty">
<Icon.NoCamera size={34} />
<b>No cameras yet</b>
<p>Add one on the Cameras screen. You will need its address on the shop network and its password.</p>
</div>
: <div className="feeds">
{cameras.map(c => (
<figure className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={`Live view from ${c.camera_id}`} />
: <div className="placeholder"><Icon.Camera size={26} /></div>}
<figcaption className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'live' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</figcaption>
</figure>
))}
</div>}
</div>
<div className="panel arrivals-panel">
<div className="panelhead">
<h3>Who just walked in</h3>
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
</div>
<div className="panel">
<div className="panelhead">
<h3>Who just walked in</h3>
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
</div>
<div className="panelbody flush">
{arrivals.length === 0
? <div className="empty">
<Icon.NoFaces size={34} />
<b>Nobody yet</b>
<p>Customers appear here the moment a camera recognises a face.</p>
</div>
: <div className="arrivals">
{arrivals.slice(0, 12).map((e, i) => (
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
))}
</div>}
</div>
<div className="panelbody flush">
{arrivals.length === 0
? <div className="empty">
<Icon.NoFaces size={34} />
<b>Nobody yet</b>
<p>Customers appear here the moment a camera recognises a face.</p>
</div>
: <div className="arrivals">
{arrivals.slice(0, 30).map((e, i) => (
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
))}
</div>}
</div>
</div>
@@ -224,27 +190,3 @@ function useFreshest(top) {
}, [top?.ts])
return fresh
}
// Stream URLs are fetched once per camera and then left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
// make every feed flicker permanently.
function useCameraFeeds() {
const [urls, setUrls] = useState({})
const { data } = usePolled(() => api.cameras(), 10000)
useEffect(() => {
let cancelled = false
;(async () => {
const next = {}
for (const cam of data ?? []) {
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
}
const changed = Object.keys(next).length !== Object.keys(urls).length ||
Object.keys(next).some(k => next[k] !== urls[k])
if (!cancelled && changed) setUrls(next)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
return urls
}