Files
lms-fe/src/renderer/public/slam-viewer/camera-preview.js
T

114 lines
3.9 KiB
JavaScript

export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
const cameras = new Map();
let enabled = false;
let timer = null;
function sources() {
const { state, receivedAt } = getState();
if (!enabled || Date.now() - receivedAt >= 3000) return [];
const list = Array.isArray(state?.cameras) ? state.cameras
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
const seen = new Set();
return list.filter(item => {
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
}).slice(0, 8);
}
function clearFrame(camera) {
if (camera.url) URL.revokeObjectURL(camera.url);
camera.url = null;
camera.image.removeAttribute('src');
}
function remove(id, camera) {
cameras.delete(id);
camera.controller?.abort();
clearFrame(camera);
camera.element.remove();
}
async function frame(id, camera) {
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
const controller = new AbortController();
camera.controller = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('Camera frame unavailable');
const blob = await response.blob();
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
const previous = camera.url;
camera.url = URL.createObjectURL(blob);
camera.image.src = camera.url;
camera.retryAt = 0;
if (previous) URL.revokeObjectURL(previous);
} catch (_) {
if (cameras.get(id) === camera) {
camera.retryAt = Date.now() + 5000;
clearFrame(camera);
}
} finally {
window.clearTimeout(timeout);
if (camera.controller === controller) camera.controller = null;
}
}
function refresh() {
const live = sources();
const ids = new Set(live.map(item => item.id));
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
for (const item of live) {
let camera = cameras.get(item.id);
if (!camera) {
const element = document.createElement('div');
element.className = 'camera-preview-item';
element.dataset.cameraId = item.id;
const label = document.createElement('div');
label.className = 'camera-preview-label';
const image = document.createElement('img');
image.className = 'camera-preview-image';
element.append(label, image);
root.append(element);
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
image.addEventListener('error', () => {
if (cameras.get(item.id) === camera) {
clearFrame(camera);
camera.retryAt = Date.now() + 5000;
}
});
cameras.set(item.id, camera);
}
camera.label.textContent = item.name || item.id;
camera.image.alt = item.name || item.id;
void frame(item.id, camera);
}
onAvailable(enabled && cameras.size > 0);
}
function visibilityChanged() {
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
else refresh();
}
document.addEventListener('visibilitychange', visibilityChanged);
function setEnabled(value) {
enabled = value;
if (timer) window.clearInterval(timer);
timer = null;
refresh();
if (enabled) timer = window.setInterval(refresh, 200);
}
return {
setEnabled,
refresh,
dispose() {
setEnabled(false);
document.removeEventListener('visibilitychange', visibilityChanged);
},
};
}