@7365admin1/layer-common 3.1.4-staging.54 → 3.1.4-staging.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CameraWall.vue +650 -0
- package/components/CameraWallTile.vue +333 -0
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +121 -0
- package/utils/camera-wall.ts +281 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="root" class="vms">
|
|
3
|
+
<!-- ------------------------------------------------------------ toolbar -->
|
|
4
|
+
<div class="vms__bar">
|
|
5
|
+
<button
|
|
6
|
+
type="button"
|
|
7
|
+
class="vms__tool"
|
|
8
|
+
:class="{ 'vms__tool--on': showPicker }"
|
|
9
|
+
:aria-pressed="showPicker"
|
|
10
|
+
@click="showPicker = !showPicker"
|
|
11
|
+
>
|
|
12
|
+
Cameras<span v-if="cameras.length" class="vms__count">{{ cameras.length }}</span>
|
|
13
|
+
</button>
|
|
14
|
+
|
|
15
|
+
<div class="vms__layouts" role="group" aria-label="Wall layout">
|
|
16
|
+
<button
|
|
17
|
+
v-for="l in WALL_LAYOUTS"
|
|
18
|
+
:key="l.key"
|
|
19
|
+
type="button"
|
|
20
|
+
class="vms__tool vms__tool--icon"
|
|
21
|
+
:class="{ 'vms__tool--on': layoutKey === l.key }"
|
|
22
|
+
:aria-pressed="layoutKey === l.key"
|
|
23
|
+
:title="`${l.label} (${l.key})`"
|
|
24
|
+
:aria-label="`${l.label} camera layout`"
|
|
25
|
+
@click="layoutKey = l.key"
|
|
26
|
+
>
|
|
27
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
|
28
|
+
<rect
|
|
29
|
+
v-for="cell in l.tiles"
|
|
30
|
+
:key="cell"
|
|
31
|
+
:x="((cell - 1) % l.columns) * (16 / l.columns) + 1"
|
|
32
|
+
:y="Math.floor((cell - 1) / l.columns) * (16 / l.columns) + 1"
|
|
33
|
+
:width="16 / l.columns - 2"
|
|
34
|
+
:height="16 / l.columns - 2"
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
/>
|
|
37
|
+
</svg>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="vms__spacer" />
|
|
42
|
+
|
|
43
|
+
<div v-if="pages > 1" class="vms__pager">
|
|
44
|
+
<button type="button" class="vms__tool" :disabled="page === 1" @click="page--">
|
|
45
|
+
Prev
|
|
46
|
+
</button>
|
|
47
|
+
<span class="vms__range">{{ pageRange || `Page ${page} of ${pages}` }}</span>
|
|
48
|
+
<button type="button" class="vms__tool" :disabled="page >= pages" @click="page++">
|
|
49
|
+
Next
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
class="vms__tool"
|
|
56
|
+
title="Fill the screen with the wall"
|
|
57
|
+
@click="toggleFullscreen"
|
|
58
|
+
>
|
|
59
|
+
Full screen
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- -------------------------------------------------------------- body -->
|
|
64
|
+
<div class="vms__body">
|
|
65
|
+
<!--
|
|
66
|
+
THE CAMERA BROWSER. Beside the wall, not on top of it: a supervisor
|
|
67
|
+
changing what they watch should still be able to see what they are
|
|
68
|
+
watching.
|
|
69
|
+
-->
|
|
70
|
+
<aside v-if="showPicker" class="vms__picker" aria-label="Cameras on this site">
|
|
71
|
+
<p class="vms__picker-head">
|
|
72
|
+
On the wall
|
|
73
|
+
<button
|
|
74
|
+
v-if="selectedIds.length"
|
|
75
|
+
type="button"
|
|
76
|
+
class="vms__link"
|
|
77
|
+
@click="selectedIds = []"
|
|
78
|
+
>
|
|
79
|
+
Reset
|
|
80
|
+
</button>
|
|
81
|
+
</p>
|
|
82
|
+
<p v-if="!cameras.length && !loading" class="vms__picker-empty">
|
|
83
|
+
No cameras yet.
|
|
84
|
+
</p>
|
|
85
|
+
<label v-for="c in cameras" :key="c._id" class="vms__pick">
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
:checked="isPicked(c._id)"
|
|
89
|
+
:disabled="!isPicked(c._id) && selectedIds.length >= layout.tiles"
|
|
90
|
+
@change="togglePick(c._id)"
|
|
91
|
+
>
|
|
92
|
+
<span class="vms__pick-name">{{ c.name || c._id }}</span>
|
|
93
|
+
<span
|
|
94
|
+
class="vms__pick-dot"
|
|
95
|
+
:class="c.status === 'active' ? 'vms__pick-dot--on' : 'vms__pick-dot--off'"
|
|
96
|
+
/>
|
|
97
|
+
</label>
|
|
98
|
+
<p v-if="selectedIds.length >= layout.tiles" class="vms__picker-note">
|
|
99
|
+
This layout holds {{ layout.tiles }}. Choose a bigger layout for more.
|
|
100
|
+
</p>
|
|
101
|
+
</aside>
|
|
102
|
+
|
|
103
|
+
<div class="vms__stage">
|
|
104
|
+
<!-- Loading, empty and failed are real states, not a blank rectangle. -->
|
|
105
|
+
<div v-if="loading" class="vms__state">
|
|
106
|
+
<p class="vms__state-head">Loading cameras…</p>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div v-else-if="failed" class="vms__state">
|
|
110
|
+
<p class="vms__state-head">Cameras could not be loaded</p>
|
|
111
|
+
<p class="vms__state-body">{{ failed }}</p>
|
|
112
|
+
<button type="button" class="vms__tool" @click="load()">Try again</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div v-else-if="!cameras.length" class="vms__state">
|
|
116
|
+
<p class="vms__state-head">No cameras set up yet</p>
|
|
117
|
+
<p class="vms__state-body">
|
|
118
|
+
CCTV cameras are added in Site Settings, under CCTV. Ask whoever manages
|
|
119
|
+
this site's settings to add one.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div
|
|
124
|
+
v-else
|
|
125
|
+
class="vms__grid"
|
|
126
|
+
:style="{
|
|
127
|
+
gridTemplateColumns: `repeat(${layout.columns}, minmax(0, 1fr))`,
|
|
128
|
+
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
|
129
|
+
}"
|
|
130
|
+
>
|
|
131
|
+
<CameraWallTile
|
|
132
|
+
v-for="(camera, i) in tiles"
|
|
133
|
+
:key="camera ? camera._id : `empty-${i}`"
|
|
134
|
+
:camera="camera"
|
|
135
|
+
:dense="dense"
|
|
136
|
+
:online="online"
|
|
137
|
+
:focused="!!camera && camera._id === focusedId"
|
|
138
|
+
@focus="focusedId = $event._id"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- ------------------------------------------------------- capabilities -->
|
|
145
|
+
<!--
|
|
146
|
+
THE CONTROLS THAT ARE NOT SWITCHED ON YET.
|
|
147
|
+
|
|
148
|
+
Drawn, dimmed, and carrying the server's own reason — never hidden. A
|
|
149
|
+
supervisor who cannot see that recorded footage exists as an idea cannot
|
|
150
|
+
ask for it, and hiding it is what stops anyone asking.
|
|
151
|
+
-->
|
|
152
|
+
<div v-if="focused" class="vms__caps">
|
|
153
|
+
<p class="vms__caps-head">{{ focused.name || focused._id }}</p>
|
|
154
|
+
<div class="vms__caps-row">
|
|
155
|
+
<div
|
|
156
|
+
v-for="c in WALL_CAPABILITY_CONTROLS"
|
|
157
|
+
:key="c.key"
|
|
158
|
+
class="vms__cap"
|
|
159
|
+
:class="{ 'vms__cap--off': !capability(c.key).available }"
|
|
160
|
+
>
|
|
161
|
+
<p class="vms__cap-title">
|
|
162
|
+
{{ c.title }}
|
|
163
|
+
<span v-if="capability(c.key).tag" class="vms__cap-tag">
|
|
164
|
+
{{ capability(c.key).tag }}
|
|
165
|
+
</span>
|
|
166
|
+
</p>
|
|
167
|
+
<p class="vms__cap-detail">{{ capability(c.key).detail }}</p>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<script setup lang="ts">
|
|
175
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
176
|
+
|
|
177
|
+
import {
|
|
178
|
+
capabilityView,
|
|
179
|
+
DEFAULT_WALL_LAYOUT,
|
|
180
|
+
isDenseLayout,
|
|
181
|
+
wallCameras,
|
|
182
|
+
wallLayout,
|
|
183
|
+
wallTiles,
|
|
184
|
+
WALL_CAPABILITY_CONTROLS,
|
|
185
|
+
WALL_LAYOUTS,
|
|
186
|
+
type TWallCamera,
|
|
187
|
+
type TWallLayoutKey,
|
|
188
|
+
} from "../utils/camera-wall";
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* THE CAMERA WALL — one component, both applications.
|
|
192
|
+
*
|
|
193
|
+
* Security has had a wall for a while and Property Management has had none, so
|
|
194
|
+
* the same product answered the same question two different ways depending on
|
|
195
|
+
* which tab you were in. This is that wall, once, in the shared layer: the
|
|
196
|
+
* apps supply the site and nothing else.
|
|
197
|
+
*
|
|
198
|
+
* ## What it is, in one paragraph
|
|
199
|
+
*
|
|
200
|
+
* A dark video wall: a layout switcher, a camera browser beside the grid, tiles
|
|
201
|
+
* that carry a name and a live status, any tile expandable to the whole screen,
|
|
202
|
+
* and — under it — the camera controls that exist but are not switched on in
|
|
203
|
+
* this deployment yet, drawn and dimmed with the server's own explanation. It
|
|
204
|
+
* is the same shape as the monitoring surfaces in the iSecure365 mobile app,
|
|
205
|
+
* on purpose.
|
|
206
|
+
*
|
|
207
|
+
* ## Why the wall is dark in a light application
|
|
208
|
+
*
|
|
209
|
+
* A supervisor scanning tiles is judging PICTURES, and every bright surface
|
|
210
|
+
* beside a picture steals the contrast the picture needs. So the wall is dark
|
|
211
|
+
* in both themes, deliberately — the rest of the application is untouched, and
|
|
212
|
+
* a light-theme user getting a dark wall is the intended answer, not a missed
|
|
213
|
+
* token.
|
|
214
|
+
*
|
|
215
|
+
* ## What it deliberately does NOT do
|
|
216
|
+
*
|
|
217
|
+
* It does not decide who may see cameras. There is no camera-VIEWING permission
|
|
218
|
+
* in the product today — only the two `site-settings:manage-*-camera` strings,
|
|
219
|
+
* which govern SETUP — so each application gates its own menu entry with a
|
|
220
|
+
* permission that actually resolves, and this component draws what it is given.
|
|
221
|
+
* Inventing a gate here would silently deny people access they have today.
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
const props = defineProps<{ site: string }>();
|
|
225
|
+
|
|
226
|
+
/* ---------------------------------------------------------------- the data */
|
|
227
|
+
|
|
228
|
+
const { getAllSiteCameras } = useSiteSettings();
|
|
229
|
+
|
|
230
|
+
const cameras = ref<TWallCamera[]>([]);
|
|
231
|
+
const page = ref(1);
|
|
232
|
+
const pages = ref(0);
|
|
233
|
+
const pageRange = ref("");
|
|
234
|
+
const loading = ref(true);
|
|
235
|
+
const failed = ref("");
|
|
236
|
+
|
|
237
|
+
async function load() {
|
|
238
|
+
loading.value = true;
|
|
239
|
+
failed.value = "";
|
|
240
|
+
try {
|
|
241
|
+
const res = await getAllSiteCameras({
|
|
242
|
+
site: props.site,
|
|
243
|
+
// The server is asked for IP cameras only, and `wallCameras` filters
|
|
244
|
+
// again on the way in: an ANPR unit belongs to visitor and vehicle
|
|
245
|
+
// management and must never appear on a monitoring wall.
|
|
246
|
+
type: "ip",
|
|
247
|
+
page: page.value,
|
|
248
|
+
});
|
|
249
|
+
cameras.value = wallCameras(res?.items);
|
|
250
|
+
pages.value = Number(res?.pages) || 0;
|
|
251
|
+
pageRange.value = String(res?.pageRange || "");
|
|
252
|
+
} catch {
|
|
253
|
+
// The reason an API call failed is rarely something an operator can act on,
|
|
254
|
+
// and the raw message is often a stack trace. Say what is true and offer
|
|
255
|
+
// the one useful action.
|
|
256
|
+
cameras.value = [];
|
|
257
|
+
failed.value = "The list of cameras did not load. Check your connection and try again.";
|
|
258
|
+
} finally {
|
|
259
|
+
loading.value = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onMounted(load);
|
|
264
|
+
watch(page, load);
|
|
265
|
+
watch(() => props.site, () => {
|
|
266
|
+
page.value = 1;
|
|
267
|
+
selectedIds.value = [];
|
|
268
|
+
load();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/* -------------------------------------------------------------- the layout */
|
|
272
|
+
|
|
273
|
+
const layoutKey = ref<TWallLayoutKey>(DEFAULT_WALL_LAYOUT);
|
|
274
|
+
const layout = computed(() => wallLayout(layoutKey.value));
|
|
275
|
+
const rows = computed(() => Math.ceil(layout.value.tiles / layout.value.columns));
|
|
276
|
+
const dense = computed(() => isDenseLayout(layoutKey.value));
|
|
277
|
+
|
|
278
|
+
const selectedIds = ref<string[]>([]);
|
|
279
|
+
const showPicker = ref(false);
|
|
280
|
+
|
|
281
|
+
const tiles = computed(() => wallTiles(cameras.value, selectedIds.value, layoutKey.value));
|
|
282
|
+
|
|
283
|
+
function isPicked(id: string) {
|
|
284
|
+
return selectedIds.value.includes(id);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function togglePick(id: string) {
|
|
288
|
+
if (isPicked(id)) selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
|
289
|
+
else if (selectedIds.value.length < layout.value.tiles)
|
|
290
|
+
selectedIds.value = [...selectedIds.value, id];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* --------------------------------------------------------- what is focused */
|
|
294
|
+
|
|
295
|
+
const focusedId = ref("");
|
|
296
|
+
const focused = computed(
|
|
297
|
+
() => tiles.value.find((c) => c && c._id === focusedId.value) || tiles.value.find(Boolean) || null
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
function capability(key: string) {
|
|
301
|
+
return capabilityView(focused.value, key);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ------------------------------------------------------- browser condition */
|
|
305
|
+
|
|
306
|
+
const online = ref(true);
|
|
307
|
+
const setOnline = () => {
|
|
308
|
+
online.value = typeof navigator === "undefined" ? true : navigator.onLine !== false;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
onMounted(() => {
|
|
312
|
+
setOnline();
|
|
313
|
+
window.addEventListener("online", setOnline);
|
|
314
|
+
window.addEventListener("offline", setOnline);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
onBeforeUnmount(() => {
|
|
318
|
+
window.removeEventListener("online", setOnline);
|
|
319
|
+
window.removeEventListener("offline", setOnline);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/* ------------------------------------------------------------- fullscreen */
|
|
323
|
+
|
|
324
|
+
const root = ref<HTMLElement | null>(null);
|
|
325
|
+
|
|
326
|
+
async function toggleFullscreen() {
|
|
327
|
+
const el = root.value;
|
|
328
|
+
if (!el) return;
|
|
329
|
+
try {
|
|
330
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
331
|
+
else await el.requestFullscreen();
|
|
332
|
+
} catch {
|
|
333
|
+
// A browser that refuses fullscreen is not worth interrupting anyone over.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<style scoped>
|
|
339
|
+
/*
|
|
340
|
+
The near-black ramp, the gutter and the radius are the same values the mobile
|
|
341
|
+
monitoring build uses, so the two surfaces read as one product. They are
|
|
342
|
+
literals rather than theme tokens because this surface is dark in BOTH themes
|
|
343
|
+
and therefore cannot read one.
|
|
344
|
+
*/
|
|
345
|
+
.vms {
|
|
346
|
+
--vms-screen: #07090c;
|
|
347
|
+
--vms-tile: #101418;
|
|
348
|
+
--vms-chrome: #161b22;
|
|
349
|
+
--vms-chrome-active: #232b36;
|
|
350
|
+
--vms-line: #232b36;
|
|
351
|
+
--vms-scrim: rgba(7, 9, 12, 0.62);
|
|
352
|
+
--vms-scrim-solid: rgba(7, 9, 12, 0.82);
|
|
353
|
+
--vms-text: #e8ecf1;
|
|
354
|
+
--vms-text-dim: #94a0b0;
|
|
355
|
+
--vms-text-faint: #5c6675;
|
|
356
|
+
--vms-accent: #5b9be0;
|
|
357
|
+
--vms-radius: 3px;
|
|
358
|
+
|
|
359
|
+
display: flex;
|
|
360
|
+
flex-direction: column;
|
|
361
|
+
background: var(--vms-screen);
|
|
362
|
+
border-radius: var(--vms-radius);
|
|
363
|
+
overflow: hidden;
|
|
364
|
+
color: var(--vms-text);
|
|
365
|
+
font-family: inherit;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.vms__bar {
|
|
369
|
+
display: flex;
|
|
370
|
+
align-items: center;
|
|
371
|
+
gap: 6px;
|
|
372
|
+
padding: 6px;
|
|
373
|
+
background: var(--vms-chrome);
|
|
374
|
+
border-bottom: 1px solid var(--vms-line);
|
|
375
|
+
flex-wrap: wrap;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.vms__spacer {
|
|
379
|
+
flex: 1 1 auto;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.vms__layouts {
|
|
383
|
+
display: flex;
|
|
384
|
+
gap: 2px;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.vms__tool {
|
|
388
|
+
display: inline-flex;
|
|
389
|
+
align-items: center;
|
|
390
|
+
gap: 6px;
|
|
391
|
+
min-height: 28px;
|
|
392
|
+
padding: 4px 10px;
|
|
393
|
+
border: 1px solid var(--vms-line);
|
|
394
|
+
border-radius: var(--vms-radius);
|
|
395
|
+
background: transparent;
|
|
396
|
+
color: var(--vms-text);
|
|
397
|
+
font-size: 12px;
|
|
398
|
+
line-height: 16px;
|
|
399
|
+
cursor: pointer;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.vms__tool:hover:not(:disabled) {
|
|
403
|
+
background: var(--vms-chrome-active);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.vms__tool:disabled {
|
|
407
|
+
/* Legible, not ghosted: see the note on the capability panel below. */
|
|
408
|
+
opacity: 0.55;
|
|
409
|
+
cursor: default;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.vms__tool--icon {
|
|
413
|
+
padding: 4px 8px;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/*
|
|
417
|
+
A SELECTED TOOL IS NOT PAINTED IN THE BRAND RED. On this screen red already
|
|
418
|
+
means "this camera is down"; a permanent red block in the toolbar of a
|
|
419
|
+
monitoring wall competes with the alarms the wall exists to show. Selection is
|
|
420
|
+
a raised neutral fill and a 2 px accent underline instead.
|
|
421
|
+
*/
|
|
422
|
+
.vms__tool--on {
|
|
423
|
+
background: var(--vms-chrome-active);
|
|
424
|
+
box-shadow: inset 0 -2px 0 var(--vms-accent);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.vms__tool:focus-visible {
|
|
428
|
+
outline: 2px solid var(--vms-accent);
|
|
429
|
+
outline-offset: 1px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.vms__count {
|
|
433
|
+
color: var(--vms-text-dim);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.vms__pager {
|
|
437
|
+
display: flex;
|
|
438
|
+
align-items: center;
|
|
439
|
+
gap: 8px;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.vms__range {
|
|
443
|
+
font-size: 12px;
|
|
444
|
+
color: var(--vms-text-dim);
|
|
445
|
+
white-space: nowrap;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.vms__body {
|
|
449
|
+
display: flex;
|
|
450
|
+
min-height: 0;
|
|
451
|
+
height: 68vh;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.vms__picker {
|
|
455
|
+
width: 220px;
|
|
456
|
+
flex: none;
|
|
457
|
+
overflow-y: auto;
|
|
458
|
+
padding: 8px;
|
|
459
|
+
background: var(--vms-chrome);
|
|
460
|
+
border-right: 1px solid var(--vms-line);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.vms__picker-head {
|
|
464
|
+
display: flex;
|
|
465
|
+
align-items: center;
|
|
466
|
+
justify-content: space-between;
|
|
467
|
+
margin: 0 0 6px;
|
|
468
|
+
font-size: 11px;
|
|
469
|
+
letter-spacing: 0.06em;
|
|
470
|
+
text-transform: uppercase;
|
|
471
|
+
color: var(--vms-text-dim);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.vms__picker-empty,
|
|
475
|
+
.vms__picker-note {
|
|
476
|
+
margin: 6px 0 0;
|
|
477
|
+
font-size: 11px;
|
|
478
|
+
line-height: 15px;
|
|
479
|
+
color: var(--vms-text-dim);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.vms__link {
|
|
483
|
+
background: none;
|
|
484
|
+
border: none;
|
|
485
|
+
color: var(--vms-accent);
|
|
486
|
+
font-size: 11px;
|
|
487
|
+
cursor: pointer;
|
|
488
|
+
padding: 0;
|
|
489
|
+
text-transform: none;
|
|
490
|
+
letter-spacing: 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.vms__pick {
|
|
494
|
+
display: flex;
|
|
495
|
+
align-items: center;
|
|
496
|
+
gap: 8px;
|
|
497
|
+
/* Dense chrome, but a 40 px row so a checkbox is not a lottery to hit. */
|
|
498
|
+
min-height: 40px;
|
|
499
|
+
padding: 0 4px;
|
|
500
|
+
font-size: 12px;
|
|
501
|
+
cursor: pointer;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.vms__pick-name {
|
|
505
|
+
flex: 1 1 auto;
|
|
506
|
+
overflow: hidden;
|
|
507
|
+
text-overflow: ellipsis;
|
|
508
|
+
white-space: nowrap;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.vms__pick-dot {
|
|
512
|
+
width: 7px;
|
|
513
|
+
height: 7px;
|
|
514
|
+
border-radius: 50%;
|
|
515
|
+
flex: none;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.vms__pick-dot--on {
|
|
519
|
+
background: #4caf50;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.vms__pick-dot--off {
|
|
523
|
+
background: var(--vms-text-faint);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.vms__stage {
|
|
527
|
+
flex: 1 1 auto;
|
|
528
|
+
min-width: 0;
|
|
529
|
+
display: flex;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.vms__grid {
|
|
533
|
+
flex: 1 1 auto;
|
|
534
|
+
display: grid;
|
|
535
|
+
/* 2 px: on a nine-tile wall the usual 8 px rhythm costs real picture. */
|
|
536
|
+
gap: 2px;
|
|
537
|
+
padding: 2px;
|
|
538
|
+
min-width: 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.vms__state {
|
|
542
|
+
flex: 1 1 auto;
|
|
543
|
+
display: flex;
|
|
544
|
+
flex-direction: column;
|
|
545
|
+
align-items: center;
|
|
546
|
+
justify-content: center;
|
|
547
|
+
gap: 8px;
|
|
548
|
+
padding: 24px;
|
|
549
|
+
text-align: center;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.vms__state-head {
|
|
553
|
+
margin: 0;
|
|
554
|
+
font-size: 14px;
|
|
555
|
+
font-weight: 600;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.vms__state-body {
|
|
559
|
+
margin: 0;
|
|
560
|
+
max-width: 46ch;
|
|
561
|
+
font-size: 12px;
|
|
562
|
+
line-height: 18px;
|
|
563
|
+
color: var(--vms-text-dim);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* ------------------------------------------------------------ capabilities */
|
|
567
|
+
|
|
568
|
+
.vms__caps {
|
|
569
|
+
padding: 10px;
|
|
570
|
+
background: var(--vms-chrome);
|
|
571
|
+
border-top: 1px solid var(--vms-line);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.vms__caps-head {
|
|
575
|
+
margin: 0 0 8px;
|
|
576
|
+
font-size: 11px;
|
|
577
|
+
letter-spacing: 0.06em;
|
|
578
|
+
text-transform: uppercase;
|
|
579
|
+
color: var(--vms-text-dim);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.vms__caps-row {
|
|
583
|
+
display: grid;
|
|
584
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
585
|
+
gap: 8px;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/*
|
|
589
|
+
A CONTROL THAT IS OFF IS OUTLINED, NOT FADED.
|
|
590
|
+
|
|
591
|
+
A faded fill is a live control with the lights down; an outline is the shape
|
|
592
|
+
of a control that is not there yet. The accent rail on the left says the same
|
|
593
|
+
thing again: a defined slot, waiting to be filled — which is literally true.
|
|
594
|
+
*/
|
|
595
|
+
.vms__cap {
|
|
596
|
+
padding: 8px 10px;
|
|
597
|
+
border: 1px solid var(--vms-line);
|
|
598
|
+
border-left: 2px solid var(--vms-accent);
|
|
599
|
+
border-radius: var(--vms-radius);
|
|
600
|
+
background: transparent;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.vms__cap-title {
|
|
604
|
+
margin: 0 0 2px;
|
|
605
|
+
display: flex;
|
|
606
|
+
align-items: center;
|
|
607
|
+
gap: 8px;
|
|
608
|
+
font-size: 12px;
|
|
609
|
+
font-weight: 600;
|
|
610
|
+
/* The TITLE stays legible even when the control is off: somebody who cannot
|
|
611
|
+
read "Recorded footage" cannot ask for it. */
|
|
612
|
+
color: var(--vms-text);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.vms__cap-tag {
|
|
616
|
+
font-size: 11px;
|
|
617
|
+
font-weight: 400;
|
|
618
|
+
padding: 1px 6px;
|
|
619
|
+
border-radius: var(--vms-radius);
|
|
620
|
+
background: var(--vms-chrome-active);
|
|
621
|
+
color: var(--vms-text-dim);
|
|
622
|
+
white-space: nowrap;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/*
|
|
626
|
+
0.55, not the Material 0.38. Every switched-off control here carries a
|
|
627
|
+
SENTENCE, and 0.38 measures about 3.3:1 against this surface — under the 4.5:1
|
|
628
|
+
a sentence needs. 0.55 measures about 5.2:1. A reason nobody can read is the
|
|
629
|
+
same as no reason at all.
|
|
630
|
+
*/
|
|
631
|
+
.vms__cap--off .vms__cap-detail {
|
|
632
|
+
opacity: 0.55;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.vms__cap-detail {
|
|
636
|
+
margin: 0;
|
|
637
|
+
font-size: 12px;
|
|
638
|
+
line-height: 17px;
|
|
639
|
+
color: var(--vms-text-dim);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.vms:fullscreen .vms__body {
|
|
643
|
+
height: auto;
|
|
644
|
+
flex: 1 1 auto;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.vms:fullscreen {
|
|
648
|
+
height: 100vh;
|
|
649
|
+
}
|
|
650
|
+
</style>
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="root"
|
|
4
|
+
class="vms-tile"
|
|
5
|
+
:class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
|
|
6
|
+
:tabindex="camera ? 0 : -1"
|
|
7
|
+
:aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
|
|
8
|
+
@click="camera && $emit('focus', camera)"
|
|
9
|
+
@dblclick="camera && toggleFullscreen()"
|
|
10
|
+
@keydown.enter="camera && toggleFullscreen()"
|
|
11
|
+
>
|
|
12
|
+
<!--
|
|
13
|
+
The picture, when there is one to show. This is an embed of the video
|
|
14
|
+
service's own player page: the stored address IS the live view, and there
|
|
15
|
+
is no other transport for live web video today.
|
|
16
|
+
-->
|
|
17
|
+
<iframe
|
|
18
|
+
v-if="camera && view.showPlayer"
|
|
19
|
+
:key="camera.host"
|
|
20
|
+
:src="camera.host"
|
|
21
|
+
class="vms-tile__frame"
|
|
22
|
+
:title="`Live view of ${camera.name || 'camera'}`"
|
|
23
|
+
allowfullscreen
|
|
24
|
+
referrerpolicy="no-referrer"
|
|
25
|
+
@load="onPlayerLoad"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<!--
|
|
29
|
+
A tile with nothing to show says so across the whole tile, not in a
|
|
30
|
+
corner. At nine tiles a supervisor is scanning, and a one-line reason
|
|
31
|
+
tucked under a black rectangle is exactly the thing that gets missed.
|
|
32
|
+
-->
|
|
33
|
+
<div v-if="view.detail" class="vms-tile__overlay">
|
|
34
|
+
<p class="vms-tile__reason">{{ view.detail }}</p>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Chrome sits OVER the picture, never around it. -->
|
|
38
|
+
<div v-if="camera" class="vms-tile__status">
|
|
39
|
+
<span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
|
|
40
|
+
<!--
|
|
41
|
+
At nine tiles the badge is the DOT ALONE: a status word beside a 6 px
|
|
42
|
+
dot on a small tile is unreadable at the distance a wall is scanned
|
|
43
|
+
from, and the dot is what is being read anyway. The word returns at
|
|
44
|
+
four tiles and one.
|
|
45
|
+
-->
|
|
46
|
+
<span v-if="!dense" class="vms-tile__state">{{ view.label }}</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="camera" class="vms-tile__chrome">
|
|
50
|
+
<!-- The NAME wins the fight for room: it is the tile's entire job. -->
|
|
51
|
+
<span class="vms-tile__name">{{ camera.name || camera._id }}</span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button
|
|
55
|
+
v-if="camera"
|
|
56
|
+
type="button"
|
|
57
|
+
class="vms-tile__expand"
|
|
58
|
+
title="Fill the screen with this camera"
|
|
59
|
+
aria-label="Fill the screen with this camera"
|
|
60
|
+
@click.stop="toggleFullscreen()"
|
|
61
|
+
>
|
|
62
|
+
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|
63
|
+
<path
|
|
64
|
+
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
stroke-width="2"
|
|
68
|
+
stroke-linecap="square"
|
|
69
|
+
/>
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
77
|
+
|
|
78
|
+
import {
|
|
79
|
+
LIVE_CONNECT_TIMEOUT_MS,
|
|
80
|
+
tileView,
|
|
81
|
+
type TPlayerState,
|
|
82
|
+
type TWallCamera,
|
|
83
|
+
} from "../utils/camera-wall";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ONE TILE OF THE CAMERA WALL.
|
|
87
|
+
*
|
|
88
|
+
* Everything this component decides comes from `utils/camera-wall.ts`; what is
|
|
89
|
+
* left here is the picture, the chrome over it, and the one thing only a
|
|
90
|
+
* browser can know — whether the player page ever loaded.
|
|
91
|
+
*
|
|
92
|
+
* ## The ceiling worth knowing before "improving" the live badge
|
|
93
|
+
*
|
|
94
|
+
* We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
|
|
95
|
+
* the page belongs to the video service, not to us, and learning more would
|
|
96
|
+
* mean reaching into another origin's document — which is both blocked by the
|
|
97
|
+
* browser and would break silently the day that page changes. So **"Live" means
|
|
98
|
+
* the player page is up**. If the service is up and the camera behind it is
|
|
99
|
+
* dead, the operator sees that service's own empty player, exactly as they do
|
|
100
|
+
* in the app they use today. Closing that honestly is a change on the video
|
|
101
|
+
* service's side, not here.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
const props = withDefaults(
|
|
105
|
+
defineProps<{
|
|
106
|
+
camera: TWallCamera | null;
|
|
107
|
+
/** Nine tiles: drop the status word, keep the dot. */
|
|
108
|
+
dense?: boolean;
|
|
109
|
+
/** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
|
|
110
|
+
online?: boolean;
|
|
111
|
+
focused?: boolean;
|
|
112
|
+
}>(),
|
|
113
|
+
{ dense: false, online: true, focused: false }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
|
|
117
|
+
|
|
118
|
+
const root = ref<HTMLElement | null>(null);
|
|
119
|
+
const player = ref<TPlayerState>("loading");
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
|
|
122
|
+
const view = computed(() =>
|
|
123
|
+
tileView(props.camera, { online: props.online, player: player.value })
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
function onPlayerLoad() {
|
|
127
|
+
player.value = "ready";
|
|
128
|
+
clearTimer();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clearTimer() {
|
|
132
|
+
if (timer) {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
timer = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Restart the clock whenever the address changes — a tile reused for a
|
|
140
|
+
* different camera must not inherit the previous one's verdict, in either
|
|
141
|
+
* direction.
|
|
142
|
+
*/
|
|
143
|
+
watch(
|
|
144
|
+
() => props.camera?.host,
|
|
145
|
+
(host) => {
|
|
146
|
+
clearTimer();
|
|
147
|
+
player.value = "loading";
|
|
148
|
+
if (!host) return;
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
if (player.value === "loading") player.value = "timeout";
|
|
151
|
+
}, LIVE_CONNECT_TIMEOUT_MS);
|
|
152
|
+
},
|
|
153
|
+
{ immediate: true }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
onBeforeUnmount(clearTimer);
|
|
157
|
+
|
|
158
|
+
/** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
|
|
159
|
+
async function toggleFullscreen() {
|
|
160
|
+
const el = root.value;
|
|
161
|
+
if (!el) return;
|
|
162
|
+
try {
|
|
163
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
164
|
+
else await el.requestFullscreen();
|
|
165
|
+
} catch {
|
|
166
|
+
// A browser that refuses fullscreen (permissions policy, an iframe, an old
|
|
167
|
+
// Safari) is not an error worth interrupting a supervisor over — the tile
|
|
168
|
+
// keeps working exactly as it did.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.vms-tile {
|
|
175
|
+
position: relative;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
background: var(--vms-tile, #101418);
|
|
178
|
+
border-radius: var(--vms-radius, 3px);
|
|
179
|
+
outline: none;
|
|
180
|
+
min-height: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.vms-tile--focused {
|
|
184
|
+
box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.vms-tile:focus-visible {
|
|
188
|
+
box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.vms-tile--empty {
|
|
192
|
+
background: transparent;
|
|
193
|
+
border: 1px dashed var(--vms-line, #232b36);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.vms-tile__frame {
|
|
197
|
+
width: 100%;
|
|
198
|
+
height: 100%;
|
|
199
|
+
border: none;
|
|
200
|
+
display: block;
|
|
201
|
+
background: #000;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.vms-tile__overlay {
|
|
205
|
+
position: absolute;
|
|
206
|
+
inset: 0;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
padding: 10px;
|
|
211
|
+
background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
|
|
212
|
+
pointer-events: none;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.vms-tile__reason {
|
|
216
|
+
margin: 0;
|
|
217
|
+
text-align: center;
|
|
218
|
+
color: var(--vms-text-dim, #94a0b0);
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
line-height: 16px;
|
|
221
|
+
max-width: 32ch;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.vms-tile--dense .vms-tile__reason {
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
line-height: 15px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.vms-tile__status {
|
|
230
|
+
position: absolute;
|
|
231
|
+
top: 6px;
|
|
232
|
+
right: 6px;
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
gap: 5px;
|
|
236
|
+
padding: 2px 6px;
|
|
237
|
+
border-radius: var(--vms-radius, 3px);
|
|
238
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
239
|
+
pointer-events: none;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.vms-tile__dot {
|
|
243
|
+
width: 7px;
|
|
244
|
+
height: 7px;
|
|
245
|
+
border-radius: 50%;
|
|
246
|
+
background: var(--vms-text-faint, #5c6675);
|
|
247
|
+
flex: none;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* State colours only. The chrome itself stays achromatic on purpose. */
|
|
251
|
+
.vms-tile__dot--live {
|
|
252
|
+
background: #4caf50;
|
|
253
|
+
}
|
|
254
|
+
.vms-tile__dot--connecting {
|
|
255
|
+
background: #fb8c00;
|
|
256
|
+
}
|
|
257
|
+
.vms-tile__dot--no-signal,
|
|
258
|
+
.vms-tile__dot--offline {
|
|
259
|
+
background: #e0241c;
|
|
260
|
+
}
|
|
261
|
+
.vms-tile__dot--unavailable {
|
|
262
|
+
background: var(--vms-text-faint, #5c6675);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.vms-tile__state {
|
|
266
|
+
color: var(--vms-text, #e8ecf1);
|
|
267
|
+
font-size: 11px;
|
|
268
|
+
line-height: 14px;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.vms-tile__chrome {
|
|
273
|
+
position: absolute;
|
|
274
|
+
left: 6px;
|
|
275
|
+
right: 6px;
|
|
276
|
+
bottom: 5px;
|
|
277
|
+
display: flex;
|
|
278
|
+
pointer-events: none;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.vms-tile__name {
|
|
282
|
+
color: var(--vms-text, #e8ecf1);
|
|
283
|
+
font-size: 12px;
|
|
284
|
+
line-height: 16px;
|
|
285
|
+
font-weight: 600;
|
|
286
|
+
padding: 1px 6px;
|
|
287
|
+
border-radius: var(--vms-radius, 3px);
|
|
288
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
289
|
+
overflow: hidden;
|
|
290
|
+
text-overflow: ellipsis;
|
|
291
|
+
white-space: nowrap;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.vms-tile--dense .vms-tile__name {
|
|
295
|
+
font-size: 11px;
|
|
296
|
+
line-height: 15px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.vms-tile__expand {
|
|
300
|
+
position: absolute;
|
|
301
|
+
top: 6px;
|
|
302
|
+
left: 6px;
|
|
303
|
+
display: flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
justify-content: center;
|
|
306
|
+
width: 24px;
|
|
307
|
+
height: 24px;
|
|
308
|
+
border-radius: var(--vms-radius, 3px);
|
|
309
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
310
|
+
color: var(--vms-text, #e8ecf1);
|
|
311
|
+
border: none;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
opacity: 0;
|
|
314
|
+
transition: opacity 120ms ease;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* The control is always THERE; it only stops competing with the picture. */
|
|
318
|
+
.vms-tile:hover .vms-tile__expand,
|
|
319
|
+
.vms-tile:focus-within .vms-tile__expand {
|
|
320
|
+
opacity: 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.vms-tile__expand:focus-visible {
|
|
324
|
+
opacity: 1;
|
|
325
|
+
outline: 2px solid var(--vms-accent, #5b9be0);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
|
|
329
|
+
.vms-tile:fullscreen {
|
|
330
|
+
border-radius: 0;
|
|
331
|
+
background: #000;
|
|
332
|
+
}
|
|
333
|
+
</style>
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
capabilityView,
|
|
6
|
+
isDenseLayout,
|
|
7
|
+
tileView,
|
|
8
|
+
wallCameras,
|
|
9
|
+
wallLayout,
|
|
10
|
+
wallTiles,
|
|
11
|
+
} from "./camera-wall.ts";
|
|
12
|
+
|
|
13
|
+
const ip = (over: Record<string, unknown> = {}) => ({
|
|
14
|
+
_id: "c1",
|
|
15
|
+
name: "GYM AREA",
|
|
16
|
+
type: "ip",
|
|
17
|
+
status: "active",
|
|
18
|
+
host: "https://example.invalid/4",
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/* ---------------------------------------------------------------- rule 1 */
|
|
23
|
+
|
|
24
|
+
test("an ANPR camera can never reach the wall", () => {
|
|
25
|
+
const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
|
|
26
|
+
const kept = wallCameras(items);
|
|
27
|
+
assert.equal(kept.length, 1);
|
|
28
|
+
assert.equal(kept[0]._id, "c1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a junk response is an empty wall, not a crash", () => {
|
|
32
|
+
assert.deepEqual(wallCameras(undefined), []);
|
|
33
|
+
assert.deepEqual(wallCameras([null, 3, "x"]), []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/* ---------------------------------------------------------- layout + order */
|
|
37
|
+
|
|
38
|
+
test("an unknown layout key falls back to four tiles", () => {
|
|
39
|
+
assert.equal(wallLayout("7x7").tiles, 4);
|
|
40
|
+
assert.equal(isDenseLayout("3x3"), true);
|
|
41
|
+
assert.equal(isDenseLayout("2x2"), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no selection means the site's own order, not a blank wall", () => {
|
|
45
|
+
const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
|
|
46
|
+
const tiles = wallTiles(cams, [], "2x2");
|
|
47
|
+
assert.equal(tiles.length, 4);
|
|
48
|
+
assert.equal(tiles[0]._id, "a");
|
|
49
|
+
assert.equal(tiles[1]._id, "b");
|
|
50
|
+
assert.equal(tiles[2], null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
|
|
54
|
+
const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
|
|
55
|
+
const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
|
|
56
|
+
assert.equal(tiles.length, 1);
|
|
57
|
+
assert.equal(tiles[0]._id, "b");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/* ---------------------------------------------------------------- rule 2/3 */
|
|
61
|
+
|
|
62
|
+
test("no descriptor at all counts as NOT available", () => {
|
|
63
|
+
const v = capabilityView(ip(), "ptz");
|
|
64
|
+
assert.equal(v.available, false);
|
|
65
|
+
assert.equal(v.tag, "Not checked yet");
|
|
66
|
+
assert.match(v.detail, /has not been asked/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the server's own sentence is shown, never rewritten", () => {
|
|
70
|
+
const cam = ip({
|
|
71
|
+
capabilities: {
|
|
72
|
+
playback: {
|
|
73
|
+
state: "unsupported",
|
|
74
|
+
detail: "No direct connection to this camera's recorder is configured on this server.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const v = capabilityView(cam, "playback");
|
|
79
|
+
assert.equal(v.tag, "Not set up yet");
|
|
80
|
+
assert.equal(
|
|
81
|
+
v.detail,
|
|
82
|
+
"No direct connection to this camera's recorder is configured on this server."
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a supported capability wears no tag", () => {
|
|
87
|
+
const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
|
|
88
|
+
assert.equal(capabilityView(cam, "playback").available, true);
|
|
89
|
+
assert.equal(capabilityView(cam, "playback").tag, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/* ------------------------------------------------------------------ rule 4 */
|
|
93
|
+
|
|
94
|
+
test("a page that has not loaded is Connecting, never Live", () => {
|
|
95
|
+
assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
|
|
96
|
+
assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
|
|
97
|
+
assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the browser being offline outranks everything and says so differently", () => {
|
|
101
|
+
assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
|
|
102
|
+
assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
|
|
106
|
+
assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
|
|
107
|
+
assert.match(tileView(ip({ host: "" })).detail, /no address/);
|
|
108
|
+
assert.match(tileView(null).detail, /No camera in this position/);
|
|
109
|
+
assert.equal(tileView(ip({ host: "" })).showPlayer, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a server that says live video is unsupported is believed", () => {
|
|
113
|
+
const cam = ip({
|
|
114
|
+
capabilities: {
|
|
115
|
+
liveVideo: { state: "unsupported", detail: "This camera is not active." },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const v = tileView(cam, { player: "ready" });
|
|
119
|
+
assert.equal(v.showPlayer, false);
|
|
120
|
+
assert.equal(v.detail, "This camera is not active.");
|
|
121
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CAMERA WALL'S RULES, WITH NO MARKUP IN THEM.
|
|
3
|
+
*
|
|
4
|
+
* `CameraWall.vue` draws the wall; this file decides what it is allowed to draw.
|
|
5
|
+
* Keeping the two apart is not tidiness — every rule below is one somebody can
|
|
6
|
+
* get wrong in a way nobody notices until a supervisor is looking at the wrong
|
|
7
|
+
* picture, so each one is stated once, here, and pinned by `camera-wall.test.ts`.
|
|
8
|
+
*
|
|
9
|
+
* ## The four rules
|
|
10
|
+
*
|
|
11
|
+
* 1. **Only `type: "ip"` cameras go on a wall.** An ANPR unit belongs to visitor
|
|
12
|
+
* and vehicle management: it is pointed at a number plate, it is wired to a
|
|
13
|
+
* barrier, and its record carries a real device address and a real credential.
|
|
14
|
+
* Putting one on a monitoring wall is a defect, not a feature. The request
|
|
15
|
+
* already asks the server for `type=ip`; `wallCameras()` filters again on the
|
|
16
|
+
* way in, because "the caller passed the right query" is an assumption and
|
|
17
|
+
* this is the rule.
|
|
18
|
+
* 2. **A capability we have not been told about is NOT available.** An absent or
|
|
19
|
+
* malformed descriptor reads as `unknown`, and `unknown` counts as no — so a
|
|
20
|
+
* browser talking to an older API cannot draw a control that would do nothing.
|
|
21
|
+
* 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
|
|
22
|
+
* sentence shown is the SERVER'S own sentence, never rewritten here, so a
|
|
23
|
+
* camera cannot explain itself two different ways on two screens.
|
|
24
|
+
* 4. **"Live" means a picture is arriving**, not "we asked for one". The tile
|
|
25
|
+
* says `Connecting…` until the player page is actually up, and `No signal`
|
|
26
|
+
* when it never comes.
|
|
27
|
+
*
|
|
28
|
+
* These match the iSecure365 mobile monitoring build one for one, deliberately:
|
|
29
|
+
* the same product should not answer the same question differently on a phone
|
|
30
|
+
* and on a laptop.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One capability's answer, as the API returns it (core's `CameraCapabilityEntry`). */
|
|
34
|
+
export type TWallCapability = {
|
|
35
|
+
state?: "supported" | "unsupported" | "unknown";
|
|
36
|
+
transport?: string | null;
|
|
37
|
+
reason?: string | null;
|
|
38
|
+
/** The server's own sentence. Displayed verbatim; never rewritten here. */
|
|
39
|
+
detail?: string | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A camera as the wall needs it. Extra fields on the record are ignored. */
|
|
43
|
+
export type TWallCamera = {
|
|
44
|
+
_id: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The stored address. For a `type: "ip"` camera this is a complete, directly
|
|
48
|
+
* renderable live-video page — it is the picture, not a fragment to resolve.
|
|
49
|
+
*/
|
|
50
|
+
host?: string;
|
|
51
|
+
type?: string;
|
|
52
|
+
status?: string;
|
|
53
|
+
capabilities?: Record<string, TWallCapability>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/* -------------------------------------------------------------------------- */
|
|
57
|
+
/* Layouts */
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The three wall sizes.
|
|
62
|
+
*
|
|
63
|
+
* Square only, and only three of them. A VMS layout picker exists so a
|
|
64
|
+
* supervisor can change what they are watching in one click; a list of eleven
|
|
65
|
+
* arrangements is a settings screen wearing a toolbar's clothes.
|
|
66
|
+
*/
|
|
67
|
+
export const WALL_LAYOUTS = [
|
|
68
|
+
{ key: "1x1", columns: 1, tiles: 1, label: "Single" },
|
|
69
|
+
{ key: "2x2", columns: 2, tiles: 4, label: "Four" },
|
|
70
|
+
{ key: "3x3", columns: 3, tiles: 9, label: "Nine" },
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
export type TWallLayoutKey = (typeof WALL_LAYOUTS)[number]["key"];
|
|
74
|
+
|
|
75
|
+
/** Four tiles: enough to be a wall, small enough to still be readable. */
|
|
76
|
+
export const DEFAULT_WALL_LAYOUT: TWallLayoutKey = "2x2";
|
|
77
|
+
|
|
78
|
+
export function wallLayout(key: string | undefined) {
|
|
79
|
+
return WALL_LAYOUTS.find((l) => l.key === key) ?? WALL_LAYOUTS[1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* At three columns a tile is small enough that the chrome starts eating the
|
|
84
|
+
* picture, so the tile drops its status WORD and keeps the dot.
|
|
85
|
+
*/
|
|
86
|
+
export function isDenseLayout(key: string | undefined) {
|
|
87
|
+
return wallLayout(key).columns >= 3;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
/* Which cameras, and in what order */
|
|
92
|
+
/* -------------------------------------------------------------------------- */
|
|
93
|
+
|
|
94
|
+
/** Rule 1. Only `type: "ip"`. See the note at the top of this file. */
|
|
95
|
+
export function wallCameras(items: unknown): TWallCamera[] {
|
|
96
|
+
if (!Array.isArray(items)) return [];
|
|
97
|
+
return items.filter(
|
|
98
|
+
(c): c is TWallCamera => !!c && typeof c === "object" && (c as TWallCamera).type === "ip"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tiles, in order, padded with `null` to the layout's size.
|
|
104
|
+
*
|
|
105
|
+
* An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
|
|
106
|
+
* and the wall falls back to the site's own order. A supervisor who clears the
|
|
107
|
+
* picker gets their site back, not a black screen.
|
|
108
|
+
*/
|
|
109
|
+
export function wallTiles(
|
|
110
|
+
cameras: TWallCamera[],
|
|
111
|
+
selectedIds: string[] | undefined,
|
|
112
|
+
layoutKey: string | undefined
|
|
113
|
+
): Array<TWallCamera | null> {
|
|
114
|
+
const { tiles } = wallLayout(layoutKey);
|
|
115
|
+
const byId = new Map(cameras.map((c) => [c._id, c]));
|
|
116
|
+
const chosen =
|
|
117
|
+
selectedIds && selectedIds.length
|
|
118
|
+
? selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c)
|
|
119
|
+
: cameras;
|
|
120
|
+
|
|
121
|
+
const out: Array<TWallCamera | null> = chosen.slice(0, tiles);
|
|
122
|
+
while (out.length < tiles) out.push(null);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* -------------------------------------------------------------------------- */
|
|
127
|
+
/* Capabilities */
|
|
128
|
+
/* -------------------------------------------------------------------------- */
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The two words a switched-off control wears.
|
|
132
|
+
*
|
|
133
|
+
* Lifted unchanged from the mobile build, and the "yet" is the whole point: it
|
|
134
|
+
* is the difference between a feature that is broken and a feature that is
|
|
135
|
+
* waiting on something somebody has to switch on.
|
|
136
|
+
*/
|
|
137
|
+
export const CAPABILITY_TAGS = {
|
|
138
|
+
unsupported: "Not set up yet",
|
|
139
|
+
unknown: "Not checked yet",
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a client with no descriptor at all should say. This is the server's own
|
|
144
|
+
* `device-not-probed` sentence, reused rather than reinvented, so an older API
|
|
145
|
+
* that sends no descriptor still explains itself in the product's own words.
|
|
146
|
+
*/
|
|
147
|
+
const NOT_PROBED = "This camera has not been asked what it can do yet.";
|
|
148
|
+
|
|
149
|
+
/** The four capabilities the wall draws a control for, and their plain names. */
|
|
150
|
+
export const WALL_CAPABILITY_CONTROLS = [
|
|
151
|
+
{ key: "playback", title: "Recorded footage" },
|
|
152
|
+
{ key: "events", title: "Events and alarms" },
|
|
153
|
+
{ key: "ptz", title: "Move camera" },
|
|
154
|
+
{ key: "presets", title: "Stored positions" },
|
|
155
|
+
] as const;
|
|
156
|
+
|
|
157
|
+
export type TCapabilityView = {
|
|
158
|
+
state: "supported" | "unsupported" | "unknown";
|
|
159
|
+
available: boolean;
|
|
160
|
+
/** `null` when the capability works — a working control needs no tag. */
|
|
161
|
+
tag: string | null;
|
|
162
|
+
/** The server's sentence, or the not-probed one. Never empty. */
|
|
163
|
+
detail: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Rule 2 and rule 3, in one place. */
|
|
167
|
+
export function capabilityView(
|
|
168
|
+
camera: TWallCamera | null | undefined,
|
|
169
|
+
key: string
|
|
170
|
+
): TCapabilityView {
|
|
171
|
+
const entry = camera?.capabilities?.[key];
|
|
172
|
+
const state =
|
|
173
|
+
entry?.state === "supported" || entry?.state === "unsupported"
|
|
174
|
+
? entry.state
|
|
175
|
+
: "unknown";
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
state,
|
|
179
|
+
available: state === "supported",
|
|
180
|
+
tag: state === "supported" ? null : CAPABILITY_TAGS[state],
|
|
181
|
+
detail: (entry?.detail || "").trim() || NOT_PROBED,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* -------------------------------------------------------------------------- */
|
|
186
|
+
/* Tile state */
|
|
187
|
+
/* -------------------------------------------------------------------------- */
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How long a player page may take to load before the tile stops claiming it is
|
|
191
|
+
* coming. Deliberately generous — lifted from the mobile build, where the
|
|
192
|
+
* argument is that reporting a working video service as dead is the worse of
|
|
193
|
+
* the two mistakes.
|
|
194
|
+
*/
|
|
195
|
+
export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
|
|
196
|
+
|
|
197
|
+
export type TTileState =
|
|
198
|
+
| "live"
|
|
199
|
+
| "connecting"
|
|
200
|
+
| "no-signal"
|
|
201
|
+
| "offline"
|
|
202
|
+
| "unavailable";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The four words a tile may say about itself.
|
|
206
|
+
*
|
|
207
|
+
* `offline` and `no-signal` are two different facts and used to share one word:
|
|
208
|
+
* "Offline" on every tile at once means the BROWSER lost the network; "No
|
|
209
|
+
* signal" on one tile means that camera is not answering. A supervisor needs to
|
|
210
|
+
* know which of those they are looking at.
|
|
211
|
+
*/
|
|
212
|
+
export const TILE_LABELS = {
|
|
213
|
+
live: "Live",
|
|
214
|
+
connecting: "Connecting…",
|
|
215
|
+
"no-signal": "No signal",
|
|
216
|
+
offline: "No connection",
|
|
217
|
+
unavailable: "Not set up yet",
|
|
218
|
+
} as const;
|
|
219
|
+
|
|
220
|
+
/** What the browser knows about the embedded player page. */
|
|
221
|
+
export type TPlayerState = "loading" | "ready" | "timeout";
|
|
222
|
+
|
|
223
|
+
export type TTileView = {
|
|
224
|
+
state: TTileState;
|
|
225
|
+
label: string;
|
|
226
|
+
/** A sentence for the states that need explaining; `null` when live. */
|
|
227
|
+
detail: string | null;
|
|
228
|
+
/** Whether the player page may be embedded at all. */
|
|
229
|
+
showPlayer: boolean;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rule 4, plus every reason a tile may have nothing to show.
|
|
234
|
+
*
|
|
235
|
+
* Order matters: the browser being offline beats everything (it explains every
|
|
236
|
+
* tile at once), then the record's own problems, then the server's verdict on
|
|
237
|
+
* live video, and only then what the embedded page is actually doing.
|
|
238
|
+
*/
|
|
239
|
+
export function tileView(
|
|
240
|
+
camera: TWallCamera | null | undefined,
|
|
241
|
+
opts: { online?: boolean; player?: TPlayerState } = {}
|
|
242
|
+
): TTileView {
|
|
243
|
+
const online = opts.online !== false;
|
|
244
|
+
const dead = (state: TTileState, detail: string | null): TTileView => ({
|
|
245
|
+
state,
|
|
246
|
+
label: TILE_LABELS[state],
|
|
247
|
+
detail,
|
|
248
|
+
showPlayer: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!camera) return dead("unavailable", "No camera in this position.");
|
|
252
|
+
if (!online)
|
|
253
|
+
return dead("offline", "This browser has no internet connection.");
|
|
254
|
+
if (camera.status && camera.status !== "active")
|
|
255
|
+
return dead("unavailable", "This camera is not active.");
|
|
256
|
+
if (!camera.host) return dead("unavailable", "This camera has no address configured.");
|
|
257
|
+
|
|
258
|
+
const live = capabilityView(camera, "liveVideo");
|
|
259
|
+
if (live.state === "unsupported") return dead("unavailable", live.detail);
|
|
260
|
+
|
|
261
|
+
const player = opts.player ?? "loading";
|
|
262
|
+
if (player === "timeout")
|
|
263
|
+
return {
|
|
264
|
+
state: "no-signal",
|
|
265
|
+
label: TILE_LABELS["no-signal"],
|
|
266
|
+
// Invented: only the browser can know the page never loaded, so the
|
|
267
|
+
// server has no reason code for it. Worded as a fact, not a diagnosis —
|
|
268
|
+
// we genuinely cannot tell a dead camera from a dead video service.
|
|
269
|
+
detail: "The live view did not load. The camera or the video service may be down.",
|
|
270
|
+
showPlayer: true,
|
|
271
|
+
};
|
|
272
|
+
if (player === "loading")
|
|
273
|
+
return {
|
|
274
|
+
state: "connecting",
|
|
275
|
+
label: TILE_LABELS.connecting,
|
|
276
|
+
detail: null,
|
|
277
|
+
showPlayer: true,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return { state: "live", label: TILE_LABELS.live, detail: null, showPlayer: true };
|
|
281
|
+
}
|