diff --git a/changelog.d/1693-cctv-frame-no-flash.md b/changelog.d/1693-cctv-frame-no-flash.md new file mode 100644 index 000000000..363d28129 --- /dev/null +++ b/changelog.d/1693-cctv-frame-no-flash.md @@ -0,0 +1,2 @@ +- Show a new CCTV camera's frame the moment it is decoded, with no fade, so + switching cameras no longer lingers on an empty preview (samehkhamis, #1693). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2487af0a7..1de6c550d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2224,7 +2224,9 @@ Frame loading, calibration editing and status display have separate modules; providers, camera placement and navigation policy retain their existing owners. Changing cameras invalidates old image callbacks and cancels an unfinished calibration edit. Failed refreshes preserve settled pixels for the same camera, -while a newly selected camera never shows the prior camera's image. Disposal +while a newly selected camera never shows the prior camera's image. A frame is +shown without a fade once it is decoded (or after one second if its decode never +settles), so a new camera's preview is blank only while its frame loads. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. ## UI disposal diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 9a5d746d7..c2ef0e776 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { CctvControls } from './cctvControls.js'; +import { CCTV_FRAME_DECODE_WAIT_MS } from './cctvFrames.js'; function element() { const classes = new Set(); @@ -59,6 +60,50 @@ test('a late image completion cannot replace a newer camera preview', (t) => { assert.equal(controls._frame.cameraId, 'b'); }); +test('a camera frame settles once decoded, and a stale decode cannot land', async (t) => { + const { controls, requests } = fixture(t); + const decodes = []; + const queue = (src, id) => { + controls._queueCctvFrame(src, id, true); + const request = requests.at(-1); + request.decode = () => + new Promise((resolve) => decodes.push({ src, resolve })); + return request; + }; + const first = queue('first.jpg', 'a'); + first.onload(); + assert.equal(controls._frame.hasFrame, false); + const second = queue('second.jpg', 'b'); + second.onload(); + decodes[1].resolve(); + await Promise.resolve(); + decodes[0].resolve(); + await Promise.resolve(); + assert.equal(controls._frame.src, 'second.jpg'); + assert.equal(controls._frame.cameraId, 'b'); + assert.equal(controls._frame.hasFrame, true); +}); + +test('a loaded frame shows when its decode rejects or never settles', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { controls, requests } = fixture(t); + controls._queueCctvFrame('broken.jpg', 'a', true); + requests[0].decode = () => Promise.reject(new Error('EncodingError')); + requests[0].onload(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(controls._frame.src, 'broken.jpg'); + assert.equal(controls._frame.hasFrame, true); + + controls._queueCctvFrame('hidden.jpg', 'b', true); + requests[1].decode = () => new Promise(() => {}); + requests[1].onload(); + assert.equal(controls._frame.hasFrame, false); + t.mock.timers.tick(CCTV_FRAME_DECODE_WAIT_MS); + assert.equal(controls._frame.src, 'hidden.jpg'); + assert.equal(controls._frame.hasFrame, true); +}); + test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index cdc9185f7..761711174 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -77,6 +77,9 @@ export function cctvSourceLabelText(label) { return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text; } +/** Longest wait for a loaded frame's decode before it is shown anyway. */ +export const CCTV_FRAME_DECODE_WAIT_MS = 1000; + /** The preview before any frame: nothing requested, nothing shown. */ export const EMPTY_CCTV_FRAME = Object.freeze({ cameraId: '', @@ -127,7 +130,28 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) { const preloader = new Image(); this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); + // Settle once the image is decoded, not merely loaded: the panel's + // then paints the frame the moment it takes the src, instead of showing + // its empty box for the decode. + // A decode that rejects after a load still has drawable data, as it was + // before decoding was awaited. A decode that never settles (a hidden tab may + // hold it until the page draws) shows the loaded frame after + // CCTV_FRAME_DECODE_WAIT_MS rather than leaving the preview loading. + preloader.onload = () => { + if (typeof preloader.decode !== 'function') { + this._settleCctvFrame(token, src, true); + return; + } + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + this._settleCctvFrame(token, src, true); + }; + const timer = setTimeout(settle, CCTV_FRAME_DECODE_WAIT_MS); + preloader.decode().then(settle, settle); + }; preloader.onerror = () => this._settleCctvFrame(token, src, false); preloader.src = src; } diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 7754e563f..fd406485d 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -250,8 +250,11 @@ html:not([lang='en']) .cctv-controls .scene-btn { height: auto; object-fit: contain; display: block; + /* No fade: a camera change hides the old frame at once (never its pixels + under the new camera) and shows the new one as soon as it is decoded. A + 180 ms fade-in after every arriving frame made each camera click linger + on black. */ opacity: 0; - transition: opacity 180ms ease; } #cctv-video {