mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(ui): render the CCTV frame preview, source badge and summary from the store
The frame lifecycle keeps its request state in an object instead of the image's dataset and publishes it; the panel binds the image's src, class and data attributes and the wrapper's loading and has-frame classes from it, so preload tokens, kept pixels on a failed refresh and clearing on a camera change are unchanged. The source badge is the pure presentCctvSourceBadge(), the live switch and the typed summary are published too. The video surface still draws into the panel's canvas.
This commit is contained in:
1 parent
6c7035f617
commit
eef3dec759
10 files changed
+302
-173
No files matched your search
@@ -87,9 +87,9 @@ They contain no application state.
|
|||||||
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
Street level (done: `StreetLevelPanel` renders the `gev-street-level`
|
||||||
store that `StreetLevelControls` publishes to; the controls keep their
|
store that `StreetLevelControls` publishes to; the controls keep their
|
||||||
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
|
listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel`
|
||||||
renders the buttons, CAL chip, meta line and calibration readout, with
|
renders the whole panel from the `gev-cctv` store, including the inline
|
||||||
its inline pose editor, from the `gev-cctv` store; the picker, frame,
|
pose editor, the frame preview, source badge and typed summary;
|
||||||
source badge and summary are still written by `CctvControls`), Layers, Display, Context and Radio, the command dock,
|
`CctvControls` still fills the picker's options), Layers, Display, Context and Radio, the command dock,
|
||||||
the phone shell, then Cockpit. Layers that write into the right rail
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ try {
|
|||||||
calDirty: false,
|
calDirty: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.__qaBadgeText =
|
|
||||||
document.getElementById('cctv-source-badge').textContent;
|
|
||||||
});
|
});
|
||||||
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
await page.waitForSelector('.weather-coverage.gev-status-badge');
|
||||||
const inventory = await page.evaluate(() => {
|
const inventory = await page.evaluate(() => {
|
||||||
@@ -120,20 +118,22 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
for (const state of ['loading', 'error', 'retained', 'ready', 'idle']) {
|
||||||
const result = await page.evaluate((state) => {
|
const result = await page.evaluate(async (state) => {
|
||||||
const owner = window.__godsEyeView.styleManager._cctvControls;
|
const owner = window.__godsEyeView.styleManager._cctvControls;
|
||||||
owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : '';
|
const frame = owner._frame;
|
||||||
owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state)
|
owner._frame = {
|
||||||
? 'true'
|
...frame,
|
||||||
: '';
|
loading: state === 'loading',
|
||||||
owner._cctvFrameWrap.classList.toggle(
|
error: ['error', 'retained'].includes(state),
|
||||||
'has-frame',
|
hasFrame: state === 'retained',
|
||||||
state === 'retained',
|
};
|
||||||
);
|
|
||||||
owner._syncCctvSourceBadge(
|
owner._syncCctvSourceBadge(
|
||||||
owner._cctvState.activeCamera,
|
owner._cctvState.activeCamera,
|
||||||
state !== 'idle',
|
state !== 'idle',
|
||||||
);
|
);
|
||||||
|
owner._frame = frame;
|
||||||
|
// The panel view renders the badge on Vue's next flush.
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
const badge = document.getElementById('cctv-source-badge');
|
const badge = document.getElementById('cctv-source-badge');
|
||||||
return {
|
return {
|
||||||
state: badge.dataset.frameState,
|
state: badge.dataset.frameState,
|
||||||
|
|||||||
+22
-51
@@ -1,59 +1,33 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { CctvControls } from './cctvControls.js';
|
import { presentCctvSourceBadge } from './cctvFrames.js';
|
||||||
import { presentCctvPanel } from './cctvPresentation.js';
|
import { presentCctvPanel } from './cctvPresentation.js';
|
||||||
import { railFixture } from './railTestFixture.mjs';
|
|
||||||
import { setLocale, t } from '../i18n/index.js';
|
import { setLocale, t } from '../i18n/index.js';
|
||||||
|
|
||||||
function fixture(t) {
|
const IDLE = Object.freeze({ loading: false, error: false, hasFrame: false });
|
||||||
const { document } = railFixture();
|
|
||||||
const controls = new CctvControls({
|
|
||||||
elements: {
|
|
||||||
_cctvSourceBadge: document.createElement('div'),
|
|
||||||
},
|
|
||||||
cctv: {},
|
|
||||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
|
||||||
});
|
|
||||||
t.after(() => controls.destroy());
|
|
||||||
return { controls, document };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
test('source and frame states retain their distinct meanings, including retained pixels', () => {
|
||||||
const { controls, document } = fixture(testContext);
|
|
||||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||||
controls._cctvFrame = document.createElement('img');
|
const expect = (label, state, frame, active = camera, enabled = true) =>
|
||||||
controls._cctvFrameWrap = document.createElement('div');
|
assert.deepEqual(presentCctvSourceBadge(active, enabled, frame), {
|
||||||
controls._cctvFrame.removeAttribute = () => {};
|
label,
|
||||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
state,
|
||||||
node.classList.remove = (...names) =>
|
});
|
||||||
names.forEach((name) => node.classList.toggle(name, false));
|
expect('SOURCE · UNKNOWN', 'idle', IDLE, camera, false);
|
||||||
}
|
expect('SOURCE · UNKNOWN', 'idle', IDLE, null);
|
||||||
const expect = (label, state, active = camera, enabled = true) => {
|
expect('FRAME · LOADING', 'loading', { ...IDLE, loading: true });
|
||||||
controls._syncCctvSourceBadge(active, enabled);
|
expect('FRAME · UNAVAILABLE', 'error', { ...IDLE, error: true });
|
||||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
|
||||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
|
||||||
};
|
|
||||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
|
||||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
|
||||||
controls._cctvFrame.dataset.loading = 'true';
|
|
||||||
expect('FRAME · LOADING', 'loading');
|
|
||||||
controls._cctvFrame.dataset.loading = '';
|
|
||||||
controls._cctvFrame.dataset.error = 'true';
|
|
||||||
expect('FRAME · UNAVAILABLE', 'error');
|
|
||||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
const retained = { ...IDLE, error: true, hasFrame: true };
|
||||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
expect('SNAPSHOT · DEGRADED', 'ready', retained);
|
||||||
controls._cctvFrame.dataset.loading = 'true';
|
expect('SNAPSHOT · DEGRADED', 'ready', { ...retained, loading: true });
|
||||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
|
||||||
controls._cctvFrame.dataset.loading = '';
|
|
||||||
controls._cctvFrame.dataset.error = '';
|
|
||||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', IDLE, {
|
||||||
...camera,
|
...camera,
|
||||||
sourceStatus: status,
|
sourceStatus: status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
expect('LIVE · OK', 'ready', IDLE, { feedType: 'live', sourceStatus: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
|
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
|
||||||
@@ -84,19 +58,16 @@ test('calibration labels follow supplied provenance and dirty/save/reset transit
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||||
const { controls } = fixture(testContext);
|
|
||||||
testContext.after(() => setLocale('en'));
|
testContext.after(() => setLocale('en'));
|
||||||
setLocale('ru');
|
setLocale('ru');
|
||||||
controls._syncCctvSourceBadge(
|
const badge = presentCctvSourceBadge(
|
||||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||||
true,
|
true,
|
||||||
|
IDLE,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(badge.label, `${t('SNAPSHOT')} · ${t('DEGRADED')}`);
|
||||||
controls._cctvSourceBadge.textContent,
|
assert.notEqual(badge.label, 'SNAPSHOT · DEGRADED');
|
||||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
assert.equal(badge.state, 'ready');
|
||||||
);
|
|
||||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
|
||||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
|
||||||
const { quality } = presentCctvPanel(
|
const { quality } = presentCctvPanel(
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
EMPTY_CCTV_FRAME,
|
||||||
_clearCctvFrame,
|
_clearCctvFrame,
|
||||||
_queueCctvFrame,
|
_queueCctvFrame,
|
||||||
|
_setCctvFrame,
|
||||||
_settleCctvFrame,
|
_settleCctvFrame,
|
||||||
_syncCctvSourceBadge,
|
_syncCctvSourceBadge,
|
||||||
} from './cctvFrames.js';
|
} from './cctvFrames.js';
|
||||||
@@ -46,6 +48,7 @@ export class CctvControls {
|
|||||||
this._cctvChipWasBusy = false;
|
this._cctvChipWasBusy = false;
|
||||||
this._cctvFrameRequestToken = 0;
|
this._cctvFrameRequestToken = 0;
|
||||||
this._cctvFramePreloader = null;
|
this._cctvFramePreloader = null;
|
||||||
|
this._frame = EMPTY_CCTV_FRAME;
|
||||||
this._calibrationEdit = null;
|
this._calibrationEdit = null;
|
||||||
this._actionGeneration = 0;
|
this._actionGeneration = 0;
|
||||||
this._initCctvPanel();
|
this._initCctvPanel();
|
||||||
@@ -84,6 +87,9 @@ export class CctvControls {
|
|||||||
_clearCctvFrame(...args) {
|
_clearCctvFrame(...args) {
|
||||||
return _clearCctvFrame.call(this, ...args);
|
return _clearCctvFrame.call(this, ...args);
|
||||||
}
|
}
|
||||||
|
_setCctvFrame(...args) {
|
||||||
|
return _setCctvFrame.call(this, ...args);
|
||||||
|
}
|
||||||
_queueCctvFrame(...args) {
|
_queueCctvFrame(...args) {
|
||||||
return _queueCctvFrame.call(this, ...args);
|
return _queueCctvFrame.call(this, ...args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ test('a late image completion cannot replace a newer camera preview', (t) => {
|
|||||||
assert.equal(requests[0].onload, null);
|
assert.equal(requests[0].onload, null);
|
||||||
requests[1].onload();
|
requests[1].onload();
|
||||||
stale();
|
stale();
|
||||||
assert.equal(controls._cctvFrame.src, 'second.jpg');
|
assert.equal(controls._frame.src, 'second.jpg');
|
||||||
assert.equal(controls._cctvFrame.dataset.cameraId, 'b');
|
assert.equal(controls._frame.cameraId, 'b');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
|
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
|
||||||
@@ -65,11 +65,11 @@ test('failed refresh preserves settled pixels, but changing cameras clears them'
|
|||||||
requests[0].onload();
|
requests[0].onload();
|
||||||
controls._queueCctvFrame('refresh.jpg', 'a', false);
|
controls._queueCctvFrame('refresh.jpg', 'a', false);
|
||||||
requests[1].onerror();
|
requests[1].onerror();
|
||||||
assert.equal(controls._cctvFrame.src, 'first.jpg');
|
assert.equal(controls._frame.src, 'first.jpg');
|
||||||
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true);
|
assert.equal(controls._frame.hasFrame, true);
|
||||||
controls._queueCctvFrame('other.jpg', 'b', true);
|
controls._queueCctvFrame('other.jpg', 'b', true);
|
||||||
assert.equal(controls._cctvFrame.src, '');
|
assert.equal(controls._frame.src, '');
|
||||||
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false);
|
assert.equal(controls._frame.hasFrame, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
|
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
|
||||||
@@ -90,7 +90,7 @@ test('destroy invalidates image callbacks and releases each subscription once',
|
|||||||
assert.equal(unsubscribed, 2);
|
assert.equal(unsubscribed, 2);
|
||||||
assert.equal(requests[0].onload, null);
|
assert.equal(requests[0].onload, null);
|
||||||
assert.equal(requests[0].onerror, null);
|
assert.equal(requests[0].onerror, null);
|
||||||
assert.equal(controls._cctvFrame.src, '');
|
assert.equal(controls._frame.src, '');
|
||||||
controls._queueCctvFrame('late.jpg', 'b', true);
|
controls._queueCctvFrame('late.jpg', 'b', true);
|
||||||
assert.equal(requests.length, 1);
|
assert.equal(requests.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
+57
-66
@@ -1,4 +1,3 @@
|
|||||||
import { updateStatusBadge } from './statusBadge.js';
|
|
||||||
import { msg, t } from '../i18n/index.js';
|
import { msg, t } from '../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +77,23 @@ export function cctvSourceLabelText(label) {
|
|||||||
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
|
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The preview before any frame: nothing requested, nothing shown. */
|
||||||
|
export const EMPTY_CCTV_FRAME = Object.freeze({
|
||||||
|
cameraId: '',
|
||||||
|
currentSrc: '',
|
||||||
|
src: '',
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
hasFrame: false,
|
||||||
|
wrapLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Apply a change to the preview and publish it to the panel view. */
|
||||||
|
export function _setCctvFrame(change) {
|
||||||
|
this._frame = { ...this._frame, ...change };
|
||||||
|
this.store.setFrame(this._frame);
|
||||||
|
}
|
||||||
|
|
||||||
export function _clearCctvFrame() {
|
export function _clearCctvFrame() {
|
||||||
this._cctvFrameRequestToken += 1;
|
this._cctvFrameRequestToken += 1;
|
||||||
if (this._cctvFramePreloader) {
|
if (this._cctvFramePreloader) {
|
||||||
@@ -85,41 +101,29 @@ export function _clearCctvFrame() {
|
|||||||
this._cctvFramePreloader.onerror = null;
|
this._cctvFramePreloader.onerror = null;
|
||||||
}
|
}
|
||||||
this._cctvFramePreloader = null;
|
this._cctvFramePreloader = null;
|
||||||
if (this._cctvFrame) {
|
if (this._cctvFrame) this._setCctvFrame(EMPTY_CCTV_FRAME);
|
||||||
this._cctvFrame.classList.remove('active');
|
|
||||||
this._cctvFrame.removeAttribute('src');
|
|
||||||
this._cctvFrame.dataset.cameraId = '';
|
|
||||||
this._cctvFrame.dataset.currentSrc = '';
|
|
||||||
this._cctvFrame.dataset.loading = '';
|
|
||||||
this._cctvFrame.dataset.error = '';
|
|
||||||
}
|
|
||||||
this._cctvFrameWrap?.classList.remove('loading', 'has-frame');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function _queueCctvFrame(src, cameraId, cameraChanged) {
|
export function _queueCctvFrame(src, cameraId, cameraChanged) {
|
||||||
if (this.destroyed || !this._cctvFrame || !src) return;
|
if (this.destroyed || !this._cctvFrame || !src) return;
|
||||||
|
|
||||||
if (cameraChanged) {
|
|
||||||
// A different camera gets an honest acquisition state. Never retain
|
|
||||||
// the prior camera's pixels under the newly selected metadata.
|
|
||||||
this._cctvFrame.classList.remove('active');
|
|
||||||
this._cctvFrame.removeAttribute('src');
|
|
||||||
this._cctvFrameWrap?.classList.remove('has-frame');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._cctvFramePreloader) {
|
if (this._cctvFramePreloader) {
|
||||||
this._cctvFramePreloader.onload = null;
|
this._cctvFramePreloader.onload = null;
|
||||||
this._cctvFramePreloader.onerror = null;
|
this._cctvFramePreloader.onerror = null;
|
||||||
}
|
}
|
||||||
const token = ++this._cctvFrameRequestToken;
|
const token = ++this._cctvFrameRequestToken;
|
||||||
this._cctvFrame.dataset.cameraId = cameraId;
|
// A different camera gets an honest acquisition state. Never retain the
|
||||||
this._cctvFrame.dataset.currentSrc = src;
|
// prior camera's pixels under the newly selected metadata.
|
||||||
this._cctvFrame.dataset.loading = 'true';
|
const hasFrame = cameraChanged ? false : this._frame.hasFrame;
|
||||||
this._cctvFrame.dataset.error = '';
|
this._setCctvFrame({
|
||||||
this._cctvFrameWrap?.classList.toggle(
|
...(cameraChanged ? { src: '' } : {}),
|
||||||
'loading',
|
hasFrame,
|
||||||
!this._cctvFrameWrap?.classList.contains('has-frame'),
|
cameraId,
|
||||||
);
|
currentSrc: src,
|
||||||
|
loading: true,
|
||||||
|
error: false,
|
||||||
|
wrapLoading: !hasFrame,
|
||||||
|
});
|
||||||
|
|
||||||
const preloader = new Image();
|
const preloader = new Image();
|
||||||
this._cctvFramePreloader = preloader;
|
this._cctvFramePreloader = preloader;
|
||||||
@@ -140,54 +144,41 @@ export function _settleCctvFrame(token, src, ok) {
|
|||||||
this._cctvFramePreloader.onerror = null;
|
this._cctvFramePreloader.onerror = null;
|
||||||
}
|
}
|
||||||
this._cctvFramePreloader = null;
|
this._cctvFramePreloader = null;
|
||||||
this._cctvFrame.dataset.loading = '';
|
// A failure leaves a settled frame on screen.
|
||||||
this._cctvFrameWrap?.classList.remove('loading');
|
this._setCctvFrame(
|
||||||
|
ok
|
||||||
const syncBadge = () =>
|
? {
|
||||||
|
loading: false,
|
||||||
|
wrapLoading: false,
|
||||||
|
error: false,
|
||||||
|
src,
|
||||||
|
hasFrame: true,
|
||||||
|
}
|
||||||
|
: { loading: false, wrapLoading: false, error: true },
|
||||||
|
);
|
||||||
this._syncCctvSourceBadge(
|
this._syncCctvSourceBadge(
|
||||||
this._cctvState?.activeCamera,
|
this._cctvState?.activeCamera,
|
||||||
!!this._cctvState?.enabled && !!this.actions.isEnabled(),
|
!!this._cctvState?.enabled && !!this.actions.isEnabled(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
// Leave the element untouched — a settled frame stays on screen.
|
|
||||||
this._cctvFrame.dataset.error = 'true';
|
|
||||||
syncBadge();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._cctvFrame.dataset.error = '';
|
/** The source badge's label and frame state for the camera and preview. */
|
||||||
this._cctvFrame.src = src;
|
export function presentCctvSourceBadge(activeCamera, enabled, frame) {
|
||||||
this._cctvFrame.classList.add('active');
|
if (!enabled || !activeCamera)
|
||||||
this._cctvFrameWrap?.classList.add('has-frame');
|
return { label: t('SOURCE · UNKNOWN'), state: 'idle' };
|
||||||
syncBadge();
|
if (frame.loading && !frame.hasFrame)
|
||||||
}
|
return { label: t('FRAME · LOADING'), state: 'loading' };
|
||||||
|
if (frame.error && !frame.hasFrame)
|
||||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
return { label: t('FRAME · UNAVAILABLE'), state: 'error' };
|
||||||
if (!this._cctvSourceBadge) return;
|
|
||||||
if (!enabled || !activeCamera) {
|
|
||||||
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
|
||||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasDisplayedFrame =
|
|
||||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
|
||||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
|
||||||
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
|
||||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
|
||||||
updateStatusBadge(this._cctvSourceBadge, {
|
|
||||||
label: t('FRAME · UNAVAILABLE'),
|
|
||||||
});
|
|
||||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = cctvSourceKindLabel(
|
const kind = cctvSourceKindLabel(
|
||||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||||
);
|
);
|
||||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||||
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
return { label: `${kind} · ${status}`, state: 'ready' };
|
||||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
}
|
||||||
|
|
||||||
|
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||||
|
this.store.setSourceBadge(
|
||||||
|
presentCctvSourceBadge(activeCamera, enabled, this._frame),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+119
-11
@@ -37,6 +37,9 @@ function panel(t) {
|
|||||||
...elements,
|
...elements,
|
||||||
_cctvPanel: document.getElementById('cctv-panel'),
|
_cctvPanel: document.getElementById('cctv-panel'),
|
||||||
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
||||||
|
_cctvFrame: document.getElementById('cctv-frame'),
|
||||||
|
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
|
||||||
|
_cctvSummary: document.getElementById('cctv-summary'),
|
||||||
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
|
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
|
||||||
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
|
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
|
||||||
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
|
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
|
||||||
@@ -146,29 +149,19 @@ test('the buttons stay the elements the controls listen on', async (t) => {
|
|||||||
assert.equal(byId('cctv-nearest-btn').disabled, true);
|
assert.equal(byId('cctv-nearest-btn').disabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-renders leave what the controls write alone', async (t) => {
|
test('re-renders leave the picker options the controls fill alone', async (t) => {
|
||||||
const { controls, byId } = panel(t);
|
const { controls, byId } = panel(t);
|
||||||
const select = byId('cctv-camera-select');
|
const select = byId('cctv-camera-select');
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = 'a';
|
option.value = 'a';
|
||||||
option.textContent = 'Austin · Congress Ave';
|
option.textContent = 'Austin · Congress Ave';
|
||||||
select.append(option);
|
select.append(option);
|
||||||
byId('cctv-frame').setAttribute('src', 'https://example.test/a.jpg');
|
|
||||||
byId('cctv-summary').textContent = 'Typed summary';
|
|
||||||
const heading = document.querySelector('[data-cal-field="heading"]');
|
|
||||||
heading.textContent = 'HDG 92°';
|
|
||||||
for (const enabled of [true, false, true]) {
|
for (const enabled of [true, false, true]) {
|
||||||
controls._renderCctvState({ enabled, count: 1, cameraIndex: [] });
|
controls._renderCctvState({ enabled, count: 1, cameraIndex: [] });
|
||||||
await nextTick();
|
await nextTick();
|
||||||
}
|
}
|
||||||
assert.equal(select.options.length, 1);
|
assert.equal(select.options.length, 1);
|
||||||
assert.equal(select.options[0].value, 'a');
|
assert.equal(select.options[0].value, 'a');
|
||||||
assert.equal(
|
|
||||||
byId('cctv-frame').getAttribute('src'),
|
|
||||||
'https://example.test/a.jpg',
|
|
||||||
);
|
|
||||||
assert.equal(byId('cctv-summary').textContent, 'Typed summary');
|
|
||||||
assert.equal(heading.textContent, 'HDG 92°');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('FOCUS waits for an active camera while the other buttons work', () => {
|
test('FOCUS waits for an active camera while the other buttons work', () => {
|
||||||
@@ -390,3 +383,118 @@ test('a second edit of the same chip gets a fresh input', async (t) => {
|
|||||||
assert.ok(second && second !== first, 'a new input');
|
assert.ok(second && second !== first, 'a new input');
|
||||||
assert.equal(second.value, '30.0');
|
assert.equal(second.value, '30.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the frame preview renders each step of a camera frame request', async (t) => {
|
||||||
|
const prior = globalThis.Image;
|
||||||
|
const requests = [];
|
||||||
|
globalThis.Image = class {
|
||||||
|
constructor() {
|
||||||
|
requests.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.Image = prior;
|
||||||
|
});
|
||||||
|
const { controls, byId } = panel(t);
|
||||||
|
const frame = byId('cctv-frame');
|
||||||
|
const wrap = byId('cctv-frame-wrap');
|
||||||
|
const badge = byId('cctv-source-badge');
|
||||||
|
const state = {
|
||||||
|
enabled: true,
|
||||||
|
count: 1,
|
||||||
|
cameraIndex: [camera],
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera: {
|
||||||
|
...camera,
|
||||||
|
frameUrl: 'a.jpg',
|
||||||
|
sourceKind: 'snapshot',
|
||||||
|
sourceStatus: 'ok',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
controls._renderCctvState(state);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(frame.dataset.cameraId, 'a');
|
||||||
|
assert.equal(frame.dataset.loading, 'true');
|
||||||
|
assert.equal(frame.hasAttribute('src'), false);
|
||||||
|
assert.equal(wrap.classList.contains('loading'), true);
|
||||||
|
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||||
|
assert.equal(badge.dataset.frameState, 'loading');
|
||||||
|
requests[0].onload();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||||
|
assert.equal(frame.dataset.loading, '');
|
||||||
|
assert.equal(frame.classList.contains('active'), true);
|
||||||
|
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||||
|
assert.equal(wrap.classList.contains('loading'), false);
|
||||||
|
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||||
|
// A failed refresh keeps the settled pixels.
|
||||||
|
const refreshed = {
|
||||||
|
...state,
|
||||||
|
activeCamera: { ...state.activeCamera, frameUrl: 'a2.jpg' },
|
||||||
|
};
|
||||||
|
controls._renderCctvState(refreshed);
|
||||||
|
requests[1].onerror();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||||
|
assert.equal(frame.dataset.error, 'true');
|
||||||
|
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||||
|
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||||
|
// Another camera clears them while its frame loads.
|
||||||
|
controls._renderCctvState({
|
||||||
|
...state,
|
||||||
|
activeCameraId: 'b',
|
||||||
|
activeCamera: { ...state.activeCamera, id: 'b', frameUrl: 'b.jpg' },
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(frame.hasAttribute('src'), false);
|
||||||
|
assert.equal(frame.dataset.cameraId, 'b');
|
||||||
|
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||||
|
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||||
|
controls._renderCctvState({ ...state, enabled: false });
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(frame.hasAttribute('src'), false);
|
||||||
|
assert.equal(frame.dataset.cameraId, '');
|
||||||
|
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||||
|
assert.equal(badge.textContent, 'SOURCE · UNKNOWN');
|
||||||
|
assert.equal(badge.dataset.frameState, 'idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a live camera swaps the still frame for the video canvas', async (t) => {
|
||||||
|
const { controls, byId } = panel(t);
|
||||||
|
assert.equal(byId('cctv-video').hidden, true);
|
||||||
|
controls._renderCctvState({
|
||||||
|
enabled: true,
|
||||||
|
count: 1,
|
||||||
|
cameraIndex: [camera],
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera: { ...camera, isVideo: true },
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(byId('cctv-video').hidden, false);
|
||||||
|
assert.equal(byId('cctv-frame').hidden, true);
|
||||||
|
controls._renderCctvState({
|
||||||
|
enabled: true,
|
||||||
|
count: 1,
|
||||||
|
cameraIndex: [camera],
|
||||||
|
activeCameraId: 'a',
|
||||||
|
activeCamera: camera,
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(byId('cctv-video').hidden, true);
|
||||||
|
assert.equal(byId('cctv-frame').hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scene summary types in, then holds the whole text', async (t) => {
|
||||||
|
const { controls, byId } = panel(t);
|
||||||
|
const text = 'Austin CCTV · 5th St / Congress Ave';
|
||||||
|
controls._typeCctvSummary(text);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(byId('cctv-summary').textContent, '');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
await nextTick();
|
||||||
|
const partial = byId('cctv-summary').textContent;
|
||||||
|
assert.ok(partial.length > 0 && partial.length < text.length, partial);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(byId('cctv-summary').textContent, text);
|
||||||
|
});
|
||||||
@@ -249,9 +249,8 @@ export function _renderCctvState(state) {
|
|||||||
this.store.publish(presentCctvPanel(state, enabled));
|
this.store.publish(presentCctvPanel(state, enabled));
|
||||||
|
|
||||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||||
|
this.store.setLive(liveIntent);
|
||||||
if (this._cctvVideo) {
|
if (this._cctvVideo) {
|
||||||
this._cctvVideo.hidden = !liveIntent;
|
|
||||||
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
|
|
||||||
const visible =
|
const visible =
|
||||||
liveIntent &&
|
liveIntent &&
|
||||||
!document.hidden &&
|
!document.hidden &&
|
||||||
@@ -271,16 +270,15 @@ export function _renderCctvState(state) {
|
|||||||
if (this._cctvFrame && !liveIntent) {
|
if (this._cctvFrame && !liveIntent) {
|
||||||
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
||||||
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
||||||
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
|
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
||||||
const frameLoading = this._cctvFrame.dataset.loading === 'true';
|
const frameLoading = this._frame.loading;
|
||||||
// A same-camera refresh waits for the current image to settle. Replacing
|
// A same-camera refresh waits for the current image to settle. Replacing
|
||||||
// src every 10 seconds can cancel a slow but healthy decode forever and
|
// src every 10 seconds can cancel a slow but healthy decode forever and
|
||||||
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
|
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
|
||||||
// immediate so navigation never waits on the prior camera's request.
|
// immediate so navigation never waits on the prior camera's request.
|
||||||
if (
|
if (
|
||||||
nextSrc &&
|
nextSrc &&
|
||||||
(cameraChanged ||
|
(cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc))
|
||||||
(!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc))
|
|
||||||
) {
|
) {
|
||||||
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
||||||
}
|
}
|
||||||
@@ -305,18 +303,18 @@ export function _typeCctvSummary(text) {
|
|||||||
this._lastCctvSummaryText = nextText;
|
this._lastCctvSummaryText = nextText;
|
||||||
|
|
||||||
clearInterval(this._cctvSummaryTypingTimer);
|
clearInterval(this._cctvSummaryTypingTimer);
|
||||||
this._cctvSummary.textContent = '';
|
this.store.setSummary('');
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
this._cctvSummaryTypingTimer = setInterval(() => {
|
this._cctvSummaryTypingTimer = setInterval(() => {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
idx += 3;
|
idx += 3;
|
||||||
if (idx >= nextText.length) {
|
if (idx >= nextText.length) {
|
||||||
this._cctvSummary.textContent = nextText;
|
this.store.setSummary(nextText);
|
||||||
clearInterval(this._cctvSummaryTypingTimer);
|
clearInterval(this._cctvSummaryTypingTimer);
|
||||||
this._cctvSummaryTypingTimer = null;
|
this._cctvSummaryTypingTimer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._cctvSummary.textContent = nextText.slice(0, idx);
|
this.store.setSummary(nextText.slice(0, idx));
|
||||||
}, 20);
|
}, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-5
@@ -2,10 +2,10 @@ import { defineStore } from 'pinia';
|
|||||||
import { markRaw, shallowRef } from 'vue';
|
import { markRaw, shallowRef } from 'vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The CCTV panel's buttons, CAL chip, meta line and calibration readout as
|
* The CCTV panel as its view renders it. CctvControls presents the buttons,
|
||||||
* its view renders them. CctvControls presents them (`presentCctvPanel()`)
|
* CAL chip, meta line and calibration readout (`presentCctvPanel()`), runs
|
||||||
* and publishes here; it keeps its listeners on the buttons and paints the
|
* the frame preview, source badge and typed summary, and publishes them
|
||||||
* picker, frame, source badge and summary itself.
|
* here; it keeps its listeners on the buttons and fills the picker itself.
|
||||||
*/
|
*/
|
||||||
export const useCctvStore = defineStore('gev-cctv', () => {
|
export const useCctvStore = defineStore('gev-cctv', () => {
|
||||||
/** The presented panel; null until the controls first render. */
|
/** The presented panel; null until the controls first render. */
|
||||||
@@ -17,6 +17,15 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
|||||||
*/
|
*/
|
||||||
const calEdit = shallowRef(null);
|
const calEdit = shallowRef(null);
|
||||||
|
|
||||||
|
/** The preview: requested and shown frame, loading and error state. */
|
||||||
|
const frame = shallowRef(null);
|
||||||
|
/** Whether the live video surface replaces the still frame. */
|
||||||
|
const live = shallowRef(false);
|
||||||
|
/** `{label, state}` for the source badge; null until first synced. */
|
||||||
|
const sourceBadge = shallowRef(null);
|
||||||
|
/** The scene summary as typed so far; null until the first one. */
|
||||||
|
const summary = shallowRef(null);
|
||||||
|
|
||||||
/** @param {object} next A fresh presentation; every publish re-renders. */
|
/** @param {object} next A fresh presentation; every publish re-renders. */
|
||||||
function publish(next) {
|
function publish(next) {
|
||||||
view.value = markRaw(next);
|
view.value = markRaw(next);
|
||||||
@@ -29,5 +38,38 @@ export const useCctvStore = defineStore('gev-cctv', () => {
|
|||||||
calEdit.value = edit ? markRaw({ ...edit, id: ++editCount }) : null;
|
calEdit.value = edit ? markRaw({ ...edit, id: ++editCount }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { view, calEdit, publish, setCalEdit };
|
/** @param {object} next */
|
||||||
|
function setFrame(next) {
|
||||||
|
frame.value = markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {boolean} on */
|
||||||
|
function setLive(on) {
|
||||||
|
live.value = Boolean(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{label: string, state: string}} next */
|
||||||
|
function setSourceBadge(next) {
|
||||||
|
sourceBadge.value = markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} text */
|
||||||
|
function setSummary(text) {
|
||||||
|
summary.value = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
calEdit,
|
||||||
|
frame,
|
||||||
|
live,
|
||||||
|
sourceBadge,
|
||||||
|
summary,
|
||||||
|
publish,
|
||||||
|
setCalEdit,
|
||||||
|
setFrame,
|
||||||
|
setLive,
|
||||||
|
setSourceBadge,
|
||||||
|
setSummary,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { t } from '../../../i18n/index.js';
|
import { t } from '../../../i18n/index.js';
|
||||||
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
import CctvCalValue from '../../cctv/CctvCalValue.vue';
|
||||||
import { useCctvStore } from '../../stores/cctv.js';
|
import { useCctvStore } from '../../stores/cctv.js';
|
||||||
|
|
||||||
// The CCTV panel, its buttons, CAL chip and meta line rendered from what
|
// The CCTV panel, rendered from what CctvControls presents and publishes;
|
||||||
// CctvControls presents; until it renders, the panel shows its initial
|
// until it renders, the panel shows its initial markup. The controls fill
|
||||||
// markup. The picker, frame, source badge and summary are written by the
|
// the picker's options, and draw the live video into its canvas.
|
||||||
// controls, so their elements carry no bindings.
|
const { view, frame, live, sourceBadge, summary } = storeToRefs(useCctvStore());
|
||||||
const { view } = storeToRefs(useCctvStore());
|
// The frame's request state, as data attributes that read "true" or are
|
||||||
|
// empty; none until the controls first publish.
|
||||||
|
const flag = (on) => (on ? 'true' : '');
|
||||||
|
const frameData = computed(() =>
|
||||||
|
frame.value
|
||||||
|
? {
|
||||||
|
'data-camera-id': frame.value.cameraId,
|
||||||
|
'data-current-src': frame.value.currentSrc,
|
||||||
|
'data-loading': flag(frame.value.loading),
|
||||||
|
'data-error': flag(frame.value.error),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,12 +34,12 @@ const { view } = storeToRefs(useCctvStore());
|
|||||||
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
|
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cyber-panel-body" data-rail-scroller>
|
<div class="cyber-panel-body" data-rail-scroller>
|
||||||
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
|
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
|
||||||
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
|
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
|
||||||
<img id="cctv-frame" alt="CCTV feed frame" />
|
<img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" />
|
||||||
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
|
<canvas id="cctv-video" class="cctv-frame" :hidden="!live"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" data-i18n>SOURCE · UNKNOWN</div>
|
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" :data-frame-state="sourceBadge?.state">{{ sourceBadge?.label ?? t('SOURCE · UNKNOWN') }}</div>
|
||||||
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
|
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
|
||||||
<div class="cctv-controls">
|
<div class="cctv-controls">
|
||||||
<button id="cctv-enable-btn" :class="['scene-btn', { active: view?.enabled }]">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</button>
|
<button id="cctv-enable-btn" :class="['scene-btn', { active: view?.enabled }]">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</button>
|
||||||
@@ -66,7 +79,7 @@ const { view } = storeToRefs(useCctvStore());
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
|
||||||
<div id="cctv-summary" data-i18n>Enable CCTV to start camera-linked intelligence summaries.</div>
|
<div id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user