diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index fd73a285e..d0f85c994 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -87,9 +87,9 @@ They contain no application state. Street level (done: `StreetLevelPanel` renders the `gev-street-level` store that `StreetLevelControls` publishes to; the controls keep their listeners, EXPAND, focus and resizing), CCTV (in progress: `CctvPanel` - renders the buttons, CAL chip, meta line and calibration readout, with - its inline pose editor, from the `gev-cctv` store; the picker, frame, - source badge and summary are still written by `CctvControls`), Layers, Display, Context and Radio, the command dock, + renders the whole panel from the `gev-cctv` store, including the inline + pose editor, the frame preview, source badge and typed summary; + `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 directly gain a slot instead. diff --git a/scripts/qa-status-badges.mjs b/scripts/qa-status-badges.mjs index 65b044891..1d3b7347b 100644 --- a/scripts/qa-status-badges.mjs +++ b/scripts/qa-status-badges.mjs @@ -69,8 +69,6 @@ try { calDirty: false, }, }); - window.__qaBadgeText = - document.getElementById('cctv-source-badge').textContent; }); await page.waitForSelector('.weather-coverage.gev-status-badge'); const inventory = await page.evaluate(() => { @@ -120,20 +118,22 @@ try { ); 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; - owner._cctvFrame.dataset.loading = state === 'loading' ? 'true' : ''; - owner._cctvFrame.dataset.error = ['error', 'retained'].includes(state) - ? 'true' - : ''; - owner._cctvFrameWrap.classList.toggle( - 'has-frame', - state === 'retained', - ); + const frame = owner._frame; + owner._frame = { + ...frame, + loading: state === 'loading', + error: ['error', 'retained'].includes(state), + hasFrame: state === 'retained', + }; owner._syncCctvSourceBadge( owner._cctvState.activeCamera, 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'); return { state: badge.dataset.frameState, diff --git a/src/ui/cctvBadges.test.mjs b/src/ui/cctvBadges.test.mjs index 0283da411..ac118c0ee 100644 --- a/src/ui/cctvBadges.test.mjs +++ b/src/ui/cctvBadges.test.mjs @@ -1,59 +1,33 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { CctvControls } from './cctvControls.js'; +import { presentCctvSourceBadge } from './cctvFrames.js'; import { presentCctvPanel } from './cctvPresentation.js'; -import { railFixture } from './railTestFixture.mjs'; import { setLocale, t } from '../i18n/index.js'; -function fixture(t) { - const { document } = railFixture(); - const controls = new CctvControls({ - elements: { - _cctvSourceBadge: document.createElement('div'), - }, - cctv: {}, - actions: { isEnabled: () => true, setPanelCollapsed() {} }, - }); - t.after(() => controls.destroy()); - return { controls, document }; -} +const IDLE = Object.freeze({ loading: false, error: false, hasFrame: false }); -test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => { - const { controls, document } = fixture(testContext); +test('source and frame states retain their distinct meanings, including retained pixels', () => { const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' }; - controls._cctvFrame = document.createElement('img'); - controls._cctvFrameWrap = document.createElement('div'); - controls._cctvFrame.removeAttribute = () => {}; - for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) { - node.classList.remove = (...names) => - names.forEach((name) => node.classList.toggle(name, false)); - } - const expect = (label, state, active = camera, enabled = true) => { - controls._syncCctvSourceBadge(active, enabled); - 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'); + const expect = (label, state, frame, active = camera, enabled = true) => + assert.deepEqual(presentCctvSourceBadge(active, enabled, frame), { + label, + state, + }); + expect('SOURCE · UNKNOWN', 'idle', IDLE, camera, false); + expect('SOURCE · UNKNOWN', 'idle', IDLE, null); + expect('FRAME · LOADING', 'loading', { ...IDLE, loading: true }); + expect('FRAME · UNAVAILABLE', 'error', { ...IDLE, error: true }); // A refresh failure keeps the settled pixels and the provider's source status. - controls._cctvFrameWrap.classList.toggle('has-frame', true); - expect('SNAPSHOT · DEGRADED', 'ready'); - controls._cctvFrame.dataset.loading = 'true'; - expect('SNAPSHOT · DEGRADED', 'ready'); - controls._cctvFrame.dataset.loading = ''; - controls._cctvFrame.dataset.error = ''; + const retained = { ...IDLE, error: true, hasFrame: true }; + expect('SNAPSHOT · DEGRADED', 'ready', retained); + expect('SNAPSHOT · DEGRADED', 'ready', { ...retained, loading: true }); for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) { - expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', { + expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', IDLE, { ...camera, 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', () => { @@ -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) => { - const { controls } = fixture(testContext); testContext.after(() => setLocale('en')); setLocale('ru'); - controls._syncCctvSourceBadge( + const badge = presentCctvSourceBadge( { sourceKind: 'snapshot', sourceStatus: 'degraded' }, true, + IDLE, ); - assert.equal( - controls._cctvSourceBadge.textContent, - `${t('SNAPSHOT')} · ${t('DEGRADED')}`, - ); - assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED'); - assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready'); + assert.equal(badge.label, `${t('SNAPSHOT')} · ${t('DEGRADED')}`); + assert.notEqual(badge.label, 'SNAPSHOT · DEGRADED'); + assert.equal(badge.state, 'ready'); const { quality } = presentCctvPanel( { enabled: true, diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 83d225e89..c6858267f 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -1,6 +1,8 @@ import { + EMPTY_CCTV_FRAME, _clearCctvFrame, _queueCctvFrame, + _setCctvFrame, _settleCctvFrame, _syncCctvSourceBadge, } from './cctvFrames.js'; @@ -46,6 +48,7 @@ export class CctvControls { this._cctvChipWasBusy = false; this._cctvFrameRequestToken = 0; this._cctvFramePreloader = null; + this._frame = EMPTY_CCTV_FRAME; this._calibrationEdit = null; this._actionGeneration = 0; this._initCctvPanel(); @@ -84,6 +87,9 @@ export class CctvControls { _clearCctvFrame(...args) { return _clearCctvFrame.call(this, ...args); } + _setCctvFrame(...args) { + return _setCctvFrame.call(this, ...args); + } _queueCctvFrame(...args) { return _queueCctvFrame.call(this, ...args); } diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 7a13aa1c0..9a5d746d7 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -55,8 +55,8 @@ test('a late image completion cannot replace a newer camera preview', (t) => { assert.equal(requests[0].onload, null); requests[1].onload(); stale(); - assert.equal(controls._cctvFrame.src, 'second.jpg'); - assert.equal(controls._cctvFrame.dataset.cameraId, 'b'); + assert.equal(controls._frame.src, 'second.jpg'); + assert.equal(controls._frame.cameraId, 'b'); }); 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(); controls._queueCctvFrame('refresh.jpg', 'a', false); requests[1].onerror(); - assert.equal(controls._cctvFrame.src, 'first.jpg'); - assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true); + assert.equal(controls._frame.src, 'first.jpg'); + assert.equal(controls._frame.hasFrame, true); controls._queueCctvFrame('other.jpg', 'b', true); - assert.equal(controls._cctvFrame.src, ''); - assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false); + assert.equal(controls._frame.src, ''); + assert.equal(controls._frame.hasFrame, false); }); 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(requests[0].onload, null); assert.equal(requests[0].onerror, null); - assert.equal(controls._cctvFrame.src, ''); + assert.equal(controls._frame.src, ''); controls._queueCctvFrame('late.jpg', 'b', true); assert.equal(requests.length, 1); }); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index 33818984d..cdc9185f7 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -1,4 +1,3 @@ -import { updateStatusBadge } from './statusBadge.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; } +/** 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() { this._cctvFrameRequestToken += 1; if (this._cctvFramePreloader) { @@ -85,41 +101,29 @@ export function _clearCctvFrame() { this._cctvFramePreloader.onerror = null; } this._cctvFramePreloader = null; - if (this._cctvFrame) { - 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'); + if (this._cctvFrame) this._setCctvFrame(EMPTY_CCTV_FRAME); } export function _queueCctvFrame(src, cameraId, cameraChanged) { 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) { this._cctvFramePreloader.onload = null; this._cctvFramePreloader.onerror = null; } const token = ++this._cctvFrameRequestToken; - this._cctvFrame.dataset.cameraId = cameraId; - this._cctvFrame.dataset.currentSrc = src; - this._cctvFrame.dataset.loading = 'true'; - this._cctvFrame.dataset.error = ''; - this._cctvFrameWrap?.classList.toggle( - 'loading', - !this._cctvFrameWrap?.classList.contains('has-frame'), - ); + // A different camera gets an honest acquisition state. Never retain the + // prior camera's pixels under the newly selected metadata. + const hasFrame = cameraChanged ? false : this._frame.hasFrame; + this._setCctvFrame({ + ...(cameraChanged ? { src: '' } : {}), + hasFrame, + cameraId, + currentSrc: src, + loading: true, + error: false, + wrapLoading: !hasFrame, + }); const preloader = new Image(); this._cctvFramePreloader = preloader; @@ -140,54 +144,41 @@ export function _settleCctvFrame(token, src, ok) { this._cctvFramePreloader.onerror = null; } this._cctvFramePreloader = null; - this._cctvFrame.dataset.loading = ''; - this._cctvFrameWrap?.classList.remove('loading'); - - const syncBadge = () => - this._syncCctvSourceBadge( - this._cctvState?.activeCamera, - !!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 = ''; - this._cctvFrame.src = src; - this._cctvFrame.classList.add('active'); - this._cctvFrameWrap?.classList.add('has-frame'); - syncBadge(); + // A failure leaves a settled frame on screen. + this._setCctvFrame( + ok + ? { + loading: false, + wrapLoading: false, + error: false, + src, + hasFrame: true, + } + : { loading: false, wrapLoading: false, error: true }, + ); + this._syncCctvSourceBadge( + this._cctvState?.activeCamera, + !!this._cctvState?.enabled && !!this.actions.isEnabled(), + ); } -export function _syncCctvSourceBadge(activeCamera, enabled) { - 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; - } +/** The source badge's label and frame state for the camera and preview. */ +export function presentCctvSourceBadge(activeCamera, enabled, frame) { + if (!enabled || !activeCamera) + return { label: t('SOURCE · UNKNOWN'), state: 'idle' }; + if (frame.loading && !frame.hasFrame) + return { label: t('FRAME · LOADING'), state: 'loading' }; + if (frame.error && !frame.hasFrame) + return { label: t('FRAME · UNAVAILABLE'), state: 'error' }; const kind = cctvSourceKindLabel( activeCamera.sourceKind || activeCamera.feedType || 'unknown', ); const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown'); - updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` }); - this._cctvSourceBadge.dataset.frameState = 'ready'; + return { label: `${kind} · ${status}`, state: 'ready' }; +} + +export function _syncCctvSourceBadge(activeCamera, enabled) { + this.store.setSourceBadge( + presentCctvSourceBadge(activeCamera, enabled, this._frame), + ); } diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 5902b4291..66771a103 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -37,6 +37,9 @@ function panel(t) { ...elements, _cctvPanel: document.getElementById('cctv-panel'), _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'), _cctvAdjustBtn: document.getElementById('cctv-adjust-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); }); -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 select = byId('cctv-camera-select'); const option = document.createElement('option'); option.value = 'a'; option.textContent = 'Austin · Congress Ave'; 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]) { controls._renderCctvState({ enabled, count: 1, cameraIndex: [] }); await nextTick(); } assert.equal(select.options.length, 1); 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', () => { @@ -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.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); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 378a79c8f..13a649ce8 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -249,9 +249,8 @@ export function _renderCctvState(state) { this.store.publish(presentCctvPanel(state, enabled)); const liveIntent = enabled && !!activeCamera?.isVideo; + this.store.setLive(liveIntent); if (this._cctvVideo) { - this._cctvVideo.hidden = !liveIntent; - if (this._cctvFrame) this._cctvFrame.hidden = liveIntent; const visible = liveIntent && !document.hidden && @@ -271,16 +270,15 @@ export function _renderCctvState(state) { if (this._cctvFrame && !liveIntent) { const nextSrc = enabled ? activeCamera?.frameUrl : null; const nextCameraId = enabled ? activeCamera?.id || '' : ''; - const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId; - const frameLoading = this._cctvFrame.dataset.loading === 'true'; + const cameraChanged = this._frame.cameraId !== nextCameraId; + const frameLoading = this._frame.loading; // 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 // leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are // immediate so navigation never waits on the prior camera's request. if ( nextSrc && - (cameraChanged || - (!frameLoading && this._cctvFrame.dataset.currentSrc !== nextSrc)) + (cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc)) ) { this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged); } @@ -305,18 +303,18 @@ export function _typeCctvSummary(text) { this._lastCctvSummaryText = nextText; clearInterval(this._cctvSummaryTypingTimer); - this._cctvSummary.textContent = ''; + this.store.setSummary(''); let idx = 0; this._cctvSummaryTypingTimer = setInterval(() => { if (this.destroyed) return; idx += 3; if (idx >= nextText.length) { - this._cctvSummary.textContent = nextText; + this.store.setSummary(nextText); clearInterval(this._cctvSummaryTypingTimer); this._cctvSummaryTypingTimer = null; return; } - this._cctvSummary.textContent = nextText.slice(0, idx); + this.store.setSummary(nextText.slice(0, idx)); }, 20); } diff --git a/src/ui/stores/cctv.js b/src/ui/stores/cctv.js index 74c3ac6a4..4fcbe704e 100644 --- a/src/ui/stores/cctv.js +++ b/src/ui/stores/cctv.js @@ -2,10 +2,10 @@ import { defineStore } from 'pinia'; import { markRaw, shallowRef } from 'vue'; /** - * The CCTV panel's buttons, CAL chip, meta line and calibration readout as - * its view renders them. CctvControls presents them (`presentCctvPanel()`) - * and publishes here; it keeps its listeners on the buttons and paints the - * picker, frame, source badge and summary itself. + * The CCTV panel as its view renders it. CctvControls presents the buttons, + * CAL chip, meta line and calibration readout (`presentCctvPanel()`), runs + * the frame preview, source badge and typed summary, and publishes them + * here; it keeps its listeners on the buttons and fills the picker itself. */ export const useCctvStore = defineStore('gev-cctv', () => { /** The presented panel; null until the controls first render. */ @@ -17,6 +17,15 @@ export const useCctvStore = defineStore('gev-cctv', () => { */ 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. */ function publish(next) { view.value = markRaw(next); @@ -29,5 +38,38 @@ export const useCctvStore = defineStore('gev-cctv', () => { 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, + }; }); diff --git a/src/ui/views/panels/CctvPanel.vue b/src/ui/views/panels/CctvPanel.vue index e46a24611..32feaa305 100644 --- a/src/ui/views/panels/CctvPanel.vue +++ b/src/ui/views/panels/CctvPanel.vue @@ -1,14 +1,27 @@