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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
desktop/frontend/dist/index.html
vendored
4
desktop/frontend/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-DmoSrGcS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CGxw2EVn.css">
|
||||
<script type="module" crossorigin src="./assets/index-Dxm9O68-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Bml5ogS2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user