@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.
@@ -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
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.4-staging.54",
5
+ "version": "3.1.4-staging.55",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -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
+ }