diff --git a/app/static/index.html b/app/static/index.html
index db4fb01..0226c13 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -1905,9 +1905,7 @@ async function initMap() {
L.control.zoom({ position: 'topright' }).addTo(map);
map.attributionControl.setPrefix('');
let activeHls = null;
- map.on('popupopen', (e) => {
- if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
- const root = e.popup.getElement();
+ const startHlsFrom = (root) => {
const v = root && root.querySelector('video[data-hls]');
if (!v) return;
const fallback = () => {
@@ -1935,6 +1933,25 @@ async function initMap() {
};
if (window.Hls) startHls();
else loadHls().then(startHls).catch(fallback);
+ };
+ map.on('popupopen', (e) => {
+ if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
+ const root = e.popup.getElement();
+ const box = root && root.querySelector('.cam-pop[data-cam-id]');
+ if (box && box.dataset.loaded !== '1') {
+ fetch(`${API}/api/cameras/${box.dataset.camId}`)
+ .then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); })
+ .then(c => {
+ box.outerHTML = camPopupHtml(c);
+ startHlsFrom(e.popup.getElement());
+ })
+ .catch(() => {
+ const ph = box.querySelector('.thumb.placeholder');
+ if (ph) ph.textContent = 'preview unavailable';
+ });
+ return;
+ }
+ startHlsFrom(root);
});
map.on('popupclose', () => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
@@ -2305,6 +2322,20 @@ function camThumb(c) {
);
return `
`;
}
+function camPopupHtml(c) {
+ return `
` +
+ `
${esc(c.location_name || 'Open camera')}` +
+ `${c.id ? camThumb(c) : '
no snapshot
'}` +
+ `
` +
+ `| Vendor | ${esc(c.vendor || '?')} ${esc(c.device_type || '')} |
` +
+ `| Source | ${esc(c.discovery_source || '?')} |
` +
+ `| First seen | ${esc((c.first_seen||'').slice(0,16).replace('T',' '))} |
` +
+ `| Last seen | ${esc((c.last_seen||'').slice(0,16).replace('T',' '))} |
` +
+ `| Coords | ${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'} |
` +
+ `
` +
+ camSourceLink(c) +
+ `
`;
+}
async function loadCams() {
if (!map) return;
if (tooZoomedOut()) {
@@ -2340,18 +2371,9 @@ async function loadCams() {
iconSize: [12, 12], iconAnchor: [6, 6],
});
camsGroup.addLayer(L.marker([c.lat, c.lon], { icon })
- .bindPopup(() => `` +
+ .bindPopup(() => `
` +
`
${esc(c.location_name || 'Open camera')}` +
- `${c.id ? camThumb(c) : '
no snapshot
'}` +
- `
` +
- `| Vendor | ${esc(c.vendor || '?')} ${esc(c.device_type || '')} |
` +
- `| Source | ${esc(c.discovery_source || '?')} |
` +
- `| First seen | ${esc((c.first_seen||'').slice(0,16).replace('T',' '))} |
` +
- `| Last seen | ${esc((c.last_seen||'').slice(0,16).replace('T',' '))} |
` +
- `| Coords | ${(c.lat!=null&&c.lon!=null) ? c.lat.toFixed(3)+', '+c.lon.toFixed(3) : 'unknown'} |
` +
- `
` +
- camSourceLink(c) +
- `
`));
+ `
loading feed…
`));
});
camsGroup.addTo(map);
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity));
diff --git a/tests/test_camera_popup_contract.py b/tests/test_camera_popup_contract.py
new file mode 100644
index 0000000..42f1bce
--- /dev/null
+++ b/tests/test_camera_popup_contract.py
@@ -0,0 +1,15 @@
+"""Camera list is slim (no URLs). Popup must fetch GET /api/cameras/{id}."""
+
+from pathlib import Path
+
+HTML = Path(__file__).resolve().parents[1] / "app/static/index.html"
+
+
+def test_popupopen_fetches_camera_detail_row():
+ html = HTML.read_text()
+ start = html.index("map.on('popupopen'")
+ end = html.index("map.on('popupclose'")
+ block = html[start:end]
+ assert "fetch(" in block
+ assert "/api/cameras/" in block
+ assert "camPopupHtml(" in block