How can I build a multi-camera monitoring dashboard? Model three layers: a device ledger (listDeviceDetailsByPage + your site map), a fixed set of slots (not one player per camera in the estate), and short-lived play credentials (getKitToken + ImouPlayer, or bindDeviceLive HLS). Default tiles to SD (streamId = 1), fetch streams only when a slot is open, cache kitToken on the BFF (not accessToken in the browser), and destroy players on close. Live, playback, PTZ, and two-way talk remain per-device capabilitiesβdonβt put joysticks and mics on every cell.
This is an architecture essay. Bandwidth rants and official FAQ pages exist elsewhere; here we care about state machines and teardown.
Why dashboards fail as βjust N video tagsβ
A dashboard is a concurrency product. If you spawn a player for every row in listDeviceDetailsByPage at login, you will hit some combination of:
Browser decode/CPU collapse
Office uplink saturation (especially HD,
streamId = 0)Live-view quota exhaustion
Stream prefetch 404s / timeouts because sources were requested before the tile existed
The fix is not a bigger instance type. The fix is slots.
Architecture
βββββββββββββββββββββββββββββββββββββββββββββββ
β UI: site picker Β· N slots Β· 1 focus pane β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
β session cookie only
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β BFF: ACL Β· accessToken Β· kitToken/URL cacheβ
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β OpenAPI: listDeviceDetailsByPage β
β getKitToken | bindDeviceLive β
ββββββββββββββββββββ¬βββββββββββββββββββββββββββ
βΌ
cameras / cloud live
| Layer | Responsibility | Anti-pattern |
|---|---|---|
| Ledger | Which cameras exist; tenant/site mapping; capability flags | Hard-coded serials; assuming consumer app = OpenAPI pool |
| Slots | Max concurrent live sessions per user/page | Infinite virtual list of live players |
| Players | Bind credential β DOM node; destroy on exit | Hidden tabs still decoding HD |
Optional focus pane: one HD (streamId = 0) + PTZ/talk/playback chrome if capability and role allow. Walls stay SD.
Slot state machine
Each slot is a small FSM. Names are yours; the transitions matter.
EMPTY
--assign(deviceId)--> ASSIGNED // no network yet
--open()------------> FETCHING_CRED // BFF mint
--cred_ok-----------> PLAYING
--cred_fail---------> ERROR
PLAYING
--pause/visibility--> PAUSED // optional: stop decode
--swap(deviceId)----> TEARING_DOWN β FETCHING_CRED
--close()-----------> TEARING_DOWN β EMPTY
ERROR
--retry/close-------> FETCHING_CRED or EMPTY
Rule: ASSIGNED must not call getKitToken / bindDeviceLive. Operators drag cameras onto a 3Γ3 grid all morning; minting nine tokens per drag is how you invent outages.
Rule: Light Application / player guidance: donβt request stream sources early. Fetch when the slot enters FETCHING_CRED because the tile is visible and the user intends to watch.
Token model
accessToken β OpenAPI on BFF only
kitToken β ImouPlayer in the browser
live HLS/RTMP β URL clients; secret
appSecret β vault
kitToken β accessToken. Document this in the dashboard README. Player docs: JS SDK. Practical cache: ~1 hour on BFF; TTL ~2 hoursβre-mint on 401-ish player errors instead of stuffing admin tokens into Wasm.
Per-slot credential cache key:
(userId or session, deviceId, channelId, streamId, mode=player|hls)
Do not share one kitToken across tenants. Do not reuse a live URL from Tenant Aβs kiosk on Tenant Bβs wall.
API timing
T0 Page load
GET /ledger?site= β list join, no live calls
T1 User fills 4 slots (ASSIGNED)
still no getKitToken / bindDeviceLive
T2 Slot viewport visible / user hits Play
POST /live-session { deviceId, streamId: 1 }
β ACL
β getKitToken OR bindDeviceLive
T3 Player init / HLS attach
T4 User closes slot or leaves page
player.destroy()
POST /live-session/end // unbind if you created a live object
drop cached cred for that slot
List API: listDeviceDetailsByPage (page sizes as documentedβdonβt assume unbounded).
Live methods: bindDeviceLive, live summary. RTMP (createDeviceRtmpLive) is rarely the wall tile; keep it off the mosaic unless you have a real media reason.
Quota: My Resources. Cap N in the UI (4 or 9 is a product decision, not a platform constant). No invented Mbps SLA.
Pseudo-code (client)
const MAX_SLOTS = 9;
function createSlot() {
return { state: "EMPTY", deviceId: null, player: null };
}
async function openSlot(slot, deviceId, bff) {
if (slot.player) await teardown(slot);
slot.deviceId = deviceId;
slot.state = "FETCHING_CRED";
const { kitToken } = await bff.liveSession({
deviceId,
streamId: 1, // SD wall default
});
slot.player = initImouPlayer({ kitToken }); // NOT accessToken
slot.state = "PLAYING";
}
async function teardown(slot) {
slot.state = "TEARING_DOWN";
try {
slot.player?.destroy?.();
} finally {
await bff.endSession({ deviceId: slot.deviceId }).catch(() => {});
slot.player = null;
slot.deviceId = null;
slot.state = "EMPTY";
}
}
window.addEventListener("pagehide", () => slots.forEach(teardown));
document.addEventListener("visibilitychange", () => {
if (document.hidden) slots.forEach(maybePauseDecode);
});
Wire initImouPlayer to current SDK options (WasmLibPath, streamId, etc.). The architecture point is lifecycle, not a frozen constructor.
Capability chrome (keep it off the mosaic)
| Control | Wall slot | Focus pane |
|---|---|---|
| Live SD | Yes | Yes |
| Live HD | No (or promote to focus) | If role allows |
| PTZ | No | If capability + role |
| Two-way talk | No | If capability + role + intent |
| Playback / incident seek | No | If recording/package allows |
Do not invent SKUs. Do not invent retention days. Do not use GB28181 as the international dashboard backbone.
Server-side slot registry (optional but useful)
If two browser tabs can open the same userβs wall, a pure client cap is leaky. A thin registry on the BFF helps:
key: userId + pageSessionId
value: set of active (deviceId, channelId) live sessions
cap: MAX_SLOTS (and maybe MAX_HD = 1)
Mint fails with 429-equivalent when the set is full. End-session and pagehide must delete members. This is your product quota in front of platform live-view quotaβnot a replacement for My Resources.
Races: double-click Play, React Strict Mode double mount, and rapid slot swap. Make openSlot idempotent per slot id: abort the in-flight mint if teardown starts. Never leave an orphan ImouPlayer in the DOM.
SSR: donβt init Wasm players on the server. Hydrate the grid as EMPTY/ASSIGNED, then open on the client.
Failure modes to design for
| Symptom | Likely cause | Product handling |
|---|---|---|
| Black screen |
accessToken in player; bad Wasm path |
Token checklist; SDK FAQ |
| 404 on stream | Prefetch before slot open; expired live object | Fetch on open; re-bind |
| First tiles OK, later fail | Quota / concurrency | Cap slots; SD default |
| Joystick errors | Fixed camera | Hide PTZ from capability |
| Mic on tile 7 talks to aisle 2 | Talk on wall | Talk only on focus |
Implementation checklist
[ ] Ledger sync job, ACL on every mint
[ ]
MAX_SLOTSconstant reviewed with ops[ ] SD default; HD is a promotion
[ ]
kitTokenβaccessTokenin code review[ ]
destroyon close, route change, pagehide[ ] No prefetch of 16 live URLs at boot
[ ] Capability flags for PTZ/talk/playback
Build the dashboard against real APIs: start at open.imoulife.com. Imou Open Platform is cloud video and AIoT focused, with APIs, SDKs, and low-code components to help vendors and developers ship monitoring walls that stay within slots, tokens, and teardownβnot sixteen immortal HD players.
Related: Video Monitoring Β· JS SDK









