@7365admin1/layer-common 3.2.1 → 3.2.2-staging.76
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/.changeset/camera-wall-gated-endpoint.md +47 -0
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/.changeset/dark-primary-contrast.md +39 -0
- package/.changeset/dashboard-dark-theme-contrast.md +15 -0
- package/.changeset/service-provider-invitation-actions.md +28 -0
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/DashboardMain.vue +84 -88
- package/components/DashboardPanel.vue +1 -1
- package/components/EntryPassInformation.vue +4 -4
- package/components/FeedbackMain.vue +7 -7
- package/components/HidAccessLogDashboard.vue +119 -44
- package/components/HidCameraCaptureDialog.vue +199 -0
- package/components/HidIntercomManagement.vue +808 -201
- package/components/HidQrCodeConfiguration.vue +907 -95
- package/components/HidServiceSettingsPanel.vue +20 -1
- package/components/HidUserEnrollment.vue +392 -81
- package/components/Layout/NavigationDrawer.vue +43 -2
- package/components/NavigationItem.vue +9 -0
- package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
- package/components/OnlineFormConfigurationForm.vue +620 -224
- package/components/OnlineFormFill.vue +36 -8
- package/components/OnlineFormsConfiguration.vue +6 -2
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/ServiceProviderInvitationPrompt.vue +150 -0
- package/components/ServiceProviderMain.vue +258 -14
- package/components/SiteSettings.vue +4 -2
- package/components/VisitorForm.vue +201 -78
- package/components/VisitorManagement.vue +2 -3
- package/components/VisitorSocketPopUp.vue +56 -2
- package/composables/useComment.ts +3 -3
- package/composables/useHidAmico.ts +127 -0
- package/composables/useHidNavigation.ts +0 -7
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useOnlineFormPages.ts +2 -0
- package/composables/useServiceProviderInvitation.ts +145 -0
- package/composables/useSipWebPhone.ts +311 -0
- package/composables/useSiteSettings.ts +50 -0
- package/composables/useVisitorSocket.ts +26 -0
- package/composables/useWebUsb.ts +127 -37
- package/package.json +3 -1
- package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
- package/plugins/secure-member.client.ts +21 -56
- package/plugins/vuetify.ts +37 -9
- package/test/visitor-socket.test.mjs +36 -0
- package/types/site.d.ts +71 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/theme.test.ts +216 -0
- package/utils/theme.ts +163 -0
- package/components/HidIdentityMapping.vue +0 -975
- package/middleware/member.ts +0 -4
- package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
|
@@ -0,0 +1,818 @@
|
|
|
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
|
+
:aria-current="focused ? 'true' : undefined"
|
|
9
|
+
@click="camera && $emit('focus', camera)"
|
|
10
|
+
@dblclick="camera && toggleFullscreen()"
|
|
11
|
+
@keydown.enter="camera && toggleFullscreen()"
|
|
12
|
+
>
|
|
13
|
+
<!--
|
|
14
|
+
The picture, when there is one to show. This is an embed of the video
|
|
15
|
+
service's own player page: the stored address IS the live view, and there
|
|
16
|
+
is no other transport for live web video today.
|
|
17
|
+
|
|
18
|
+
The STAGE around it is what zooms: the transform is on our own element,
|
|
19
|
+
so magnifying the picture needs nothing from inside that page and reaches
|
|
20
|
+
into no other origin.
|
|
21
|
+
-->
|
|
22
|
+
<div
|
|
23
|
+
v-if="camera && view.showPlayer"
|
|
24
|
+
class="vms-tile__stage"
|
|
25
|
+
:style="stageStyle"
|
|
26
|
+
>
|
|
27
|
+
<iframe
|
|
28
|
+
ref="frameEl"
|
|
29
|
+
:key="camera.host"
|
|
30
|
+
:src="camera.host"
|
|
31
|
+
class="vms-tile__frame"
|
|
32
|
+
:title="`Live view of ${camera.name || 'camera'}`"
|
|
33
|
+
allowfullscreen
|
|
34
|
+
referrerpolicy="no-referrer"
|
|
35
|
+
@load="onPlayerLoad"
|
|
36
|
+
@error="onPlayerError"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!--
|
|
41
|
+
THE LAYER THAT MAKES THE TILE A TILE AGAIN.
|
|
42
|
+
|
|
43
|
+
An iframe swallows every pointer event that lands on it, so before this
|
|
44
|
+
existed a click on a tile that was actually showing a picture never
|
|
45
|
+
reached us: selecting a camera only worked on the tiles with no video in
|
|
46
|
+
them, which is the opposite of useful. This transparent layer sits over
|
|
47
|
+
the picture so a click selects, a double-click goes full screen, and —
|
|
48
|
+
when the server says this camera supports it — a wheel zooms and a drag
|
|
49
|
+
pans. The player page underneath has no controls of its own to lose.
|
|
50
|
+
-->
|
|
51
|
+
<div
|
|
52
|
+
v-if="camera && view.showPlayer"
|
|
53
|
+
class="vms-tile__grab"
|
|
54
|
+
:class="{ 'vms-tile__grab--zoomed': zoom > 1 }"
|
|
55
|
+
@wheel="onWheel"
|
|
56
|
+
@pointerdown="onPointerDown"
|
|
57
|
+
@pointermove="onPointerMove"
|
|
58
|
+
@pointerup="onPointerEnd"
|
|
59
|
+
@pointercancel="onPointerEnd"
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<!--
|
|
63
|
+
A tile with nothing to show says so across the whole tile, not in a
|
|
64
|
+
corner. At nine tiles a supervisor is scanning, and a one-line reason
|
|
65
|
+
tucked under a black rectangle is exactly the thing that gets missed.
|
|
66
|
+
-->
|
|
67
|
+
<div v-if="view.detail" class="vms-tile__overlay">
|
|
68
|
+
<p class="vms-tile__reason">{{ view.detail }}</p>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<!-- Chrome sits OVER the picture, never around it. -->
|
|
72
|
+
<!--
|
|
73
|
+
THE STATUS WORD IS NEVER DROPPED, AT ANY LAYOUT.
|
|
74
|
+
|
|
75
|
+
It used to be: at nine tiles the badge became a bare 7 px dot, on the
|
|
76
|
+
argument that the word was unreadable at wall-scanning distance. That is
|
|
77
|
+
a phone's argument. Nine tiles on a desktop wall is the layout a
|
|
78
|
+
supervisor watches from furthest away and the one where a dying camera is
|
|
79
|
+
most likely to go unnoticed, so it is the last place to remove the only
|
|
80
|
+
words that say whether a camera is alive. The type shrinks; the fact
|
|
81
|
+
stays.
|
|
82
|
+
-->
|
|
83
|
+
<div v-if="camera" class="vms-tile__status" :title="view.note || undefined">
|
|
84
|
+
<span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
|
|
85
|
+
<span class="vms-tile__state">{{ view.label }}</span>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div v-if="camera" class="vms-tile__chrome">
|
|
89
|
+
<!-- The NAME wins the fight for room: it is the tile's entire job. -->
|
|
90
|
+
<span class="vms-tile__name">{{ camera.name || camera._id }}</span>
|
|
91
|
+
<!--
|
|
92
|
+
RULE 6: the RECORDER's state, kept apart from the badge above, which is
|
|
93
|
+
about the player page. At nine tiles it keeps its phrase and loses only
|
|
94
|
+
the timestamp — a dropped recorder is not a detail to hide on the
|
|
95
|
+
busiest layout.
|
|
96
|
+
-->
|
|
97
|
+
<span
|
|
98
|
+
v-if="healthLine"
|
|
99
|
+
class="vms-tile__health"
|
|
100
|
+
:title="healthTitle"
|
|
101
|
+
>
|
|
102
|
+
<span class="vms-tile__dot" :class="`vms-tile__dot--h-${recorder.tone}`" />
|
|
103
|
+
{{ healthLine }}
|
|
104
|
+
</span>
|
|
105
|
+
|
|
106
|
+
<!--
|
|
107
|
+
THE ZOOM READOUT AND THE WAY BACK.
|
|
108
|
+
|
|
109
|
+
Only drawn once the picture is actually magnified. A supervisor who has
|
|
110
|
+
zoomed into a corner needs one obvious control out, and needs to be
|
|
111
|
+
able to tell a magnified picture from a camera that has been moved —
|
|
112
|
+
hence the "2.0x" reading rather than a bare arrow. In the same row as
|
|
113
|
+
the name, so it can never sit on top of it.
|
|
114
|
+
-->
|
|
115
|
+
<span v-if="zoom > 1" class="vms-tile__zoom">
|
|
116
|
+
<span class="vms-tile__zoom-level">{{ zoom.toFixed(1) }}x</span>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
class="vms-tile__zoom-reset"
|
|
120
|
+
title="Back to the whole picture"
|
|
121
|
+
@click.stop="resetZoom"
|
|
122
|
+
>
|
|
123
|
+
Reset
|
|
124
|
+
</button>
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<button
|
|
129
|
+
v-if="camera"
|
|
130
|
+
type="button"
|
|
131
|
+
class="vms-tile__expand"
|
|
132
|
+
title="Fill the screen with this camera"
|
|
133
|
+
aria-label="Fill the screen with this camera"
|
|
134
|
+
@click.stop="toggleFullscreen()"
|
|
135
|
+
>
|
|
136
|
+
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|
137
|
+
<path
|
|
138
|
+
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
stroke-width="2"
|
|
142
|
+
stroke-linecap="square"
|
|
143
|
+
/>
|
|
144
|
+
</svg>
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<script setup lang="ts">
|
|
150
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
151
|
+
|
|
152
|
+
import {
|
|
153
|
+
capabilityView,
|
|
154
|
+
clampPan,
|
|
155
|
+
clampZoom,
|
|
156
|
+
healthView,
|
|
157
|
+
LIVE_CONNECT_TIMEOUT_MS,
|
|
158
|
+
LIVE_FRAME_TIMEOUT_MS,
|
|
159
|
+
playerOrigin,
|
|
160
|
+
playerSignal,
|
|
161
|
+
tileView,
|
|
162
|
+
zoomStep,
|
|
163
|
+
type TPlayerState,
|
|
164
|
+
type TWallCamera,
|
|
165
|
+
type TWallCameraHealth,
|
|
166
|
+
} from "../utils/camera-wall";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* ONE TILE OF THE CAMERA WALL.
|
|
170
|
+
*
|
|
171
|
+
* Everything this component decides comes from `utils/camera-wall.ts`; what is
|
|
172
|
+
* left here is the picture, the chrome over it, and the one thing only a
|
|
173
|
+
* browser can know — whether the player page ever loaded.
|
|
174
|
+
*
|
|
175
|
+
* ## The ceiling, and what the badge is now allowed to claim
|
|
176
|
+
*
|
|
177
|
+
* We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
|
|
178
|
+
* the page belongs to the video service, not to us, and reaching into another
|
|
179
|
+
* origin's document is blocked by the browser — correctly.
|
|
180
|
+
*
|
|
181
|
+
* That ceiling has not moved. What has changed is that the badge no longer
|
|
182
|
+
* pretends otherwise. A loaded page earns **"Not confirmed"**, not "Live"; only
|
|
183
|
+
* a frame the video service itself reports over `postMessage` earns "Live".
|
|
184
|
+
* The service sends nothing today, so every tile on this estate sits at "Not
|
|
185
|
+
* confirmed" — which is the true answer. It went in because on the live wall two
|
|
186
|
+
* cameras rendered a blank rectangle under a green "Live" badge, which is the
|
|
187
|
+
* one thing a monitoring screen must never do.
|
|
188
|
+
*
|
|
189
|
+
* The listener below is inert until the video service emits. The change needed
|
|
190
|
+
* there is small — jsmpeg already calls back on every decoded frame, so
|
|
191
|
+
* `parent.postMessage({ event: "frame" }, "*")` from its `onVideoDecode` and
|
|
192
|
+
* `{ event: "stalled" }` from `onStalled` is the whole of it — but it lives in
|
|
193
|
+
* another repository and another team's deployment, and is written up in the
|
|
194
|
+
* PR rather than done here.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
const props = withDefaults(
|
|
198
|
+
defineProps<{
|
|
199
|
+
camera: TWallCamera | null;
|
|
200
|
+
/** Nine tiles: drop the status word, keep the dot. */
|
|
201
|
+
dense?: boolean;
|
|
202
|
+
/** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
|
|
203
|
+
online?: boolean;
|
|
204
|
+
focused?: boolean;
|
|
205
|
+
/** The recorder's own state, from the health sweep. Separate fact — rule 6. */
|
|
206
|
+
health?: TWallCameraHealth | null;
|
|
207
|
+
/** Recomputed by the wall on its own clock so "4 min ago" does not freeze. */
|
|
208
|
+
now?: number;
|
|
209
|
+
}>(),
|
|
210
|
+
{ dense: false, online: true, focused: false, health: null, now: 0 }
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
|
|
214
|
+
|
|
215
|
+
const root = ref<HTMLElement | null>(null);
|
|
216
|
+
const frameEl = ref<HTMLIFrameElement | null>(null);
|
|
217
|
+
const player = ref<TPlayerState>("loading");
|
|
218
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
219
|
+
let frameTimer: ReturnType<typeof setTimeout> | null = null;
|
|
220
|
+
|
|
221
|
+
const view = computed(() =>
|
|
222
|
+
tileView(props.camera, { online: props.online, player: player.value })
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------ health */
|
|
226
|
+
|
|
227
|
+
const recorder = computed(() => healthView(props.health, props.now || Date.now()));
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One line, and the last-picture time only when there is one. A tile that has
|
|
231
|
+
* never served a picture must not read as "last picture: never" — it has its
|
|
232
|
+
* own reason across the tile already, and repeating it small and grey is noise.
|
|
233
|
+
*/
|
|
234
|
+
const healthLine = computed(() => {
|
|
235
|
+
if (!props.health) return "";
|
|
236
|
+
const { label, lastFrame } = recorder.value;
|
|
237
|
+
// Nine tiles: the phrase stays, the timestamp goes. "Recorder not responding"
|
|
238
|
+
// is the fact; "· 4 min ago" is the detail, and detail is what a small tile
|
|
239
|
+
// gives up.
|
|
240
|
+
return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Everything the line had no room for, on hover. The firmware-and-clock
|
|
245
|
+
* sentence used to be appended here too; it is gone for the same reason it is
|
|
246
|
+
* gone from the panel — this screen never showed either value, so explaining
|
|
247
|
+
* their absence gave the reader nothing to do. See `THealthView`.
|
|
248
|
+
*/
|
|
249
|
+
const healthTitle = computed(() => recorder.value.detail || "");
|
|
250
|
+
|
|
251
|
+
/* -------------------------------------------------------------------- zoom */
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* DIGITAL ZOOM — armed by the SERVER, not by this component.
|
|
255
|
+
*
|
|
256
|
+
* `digitalZoom` is a capability like any other, and `unknown` counts as no: a
|
|
257
|
+
* browser talking to an API that predates the descriptor gets no zoom rather
|
|
258
|
+
* than a gesture that silently does nothing. Nothing here contacts a device —
|
|
259
|
+
* this crops and enlarges the picture already on the screen — but the rule is
|
|
260
|
+
* the rule, and honouring it is what keeps zoom and "Move camera" visibly
|
|
261
|
+
* different things.
|
|
262
|
+
*/
|
|
263
|
+
const canZoom = computed(
|
|
264
|
+
() => view.value.showPlayer && capabilityView(props.camera, "digitalZoom").available
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const zoom = ref(1);
|
|
268
|
+
const pan = ref({ x: 0, y: 0 });
|
|
269
|
+
const frame = ref({ width: 0, height: 0 });
|
|
270
|
+
|
|
271
|
+
/** Live pointers, by id: two of them is a pinch, one is a drag. */
|
|
272
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
273
|
+
let dragFrom = { x: 0, y: 0, pan: { x: 0, y: 0 }, distance: 0, zoom: 1 };
|
|
274
|
+
|
|
275
|
+
const stageStyle = computed(() => ({
|
|
276
|
+
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
function resetZoom() {
|
|
280
|
+
zoom.value = 1;
|
|
281
|
+
pan.value = { x: 0, y: 0 };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function measure(el: HTMLElement | null) {
|
|
285
|
+
if (!el) return;
|
|
286
|
+
const box = el.getBoundingClientRect();
|
|
287
|
+
frame.value = { width: box.width, height: box.height };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function setZoom(next: number) {
|
|
291
|
+
const scale = clampZoom(next);
|
|
292
|
+
zoom.value = scale;
|
|
293
|
+
pan.value = clampPan(pan.value, scale, frame.value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function onWheel(event: WheelEvent) {
|
|
297
|
+
if (!canZoom.value) return;
|
|
298
|
+
// At 1x, scrolling DOWN is somebody scrolling the page and must stay that
|
|
299
|
+
// way — a wall two thirds of the way down a page that traps the wheel is
|
|
300
|
+
// a page you cannot get past.
|
|
301
|
+
if (zoom.value <= 1 && event.deltaY > 0) return;
|
|
302
|
+
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
measure(event.currentTarget as HTMLElement);
|
|
305
|
+
setZoom(zoomStep(zoom.value, event.deltaY));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function onPointerDown(event: PointerEvent) {
|
|
309
|
+
if (!canZoom.value) return;
|
|
310
|
+
const el = event.currentTarget as HTMLElement;
|
|
311
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
312
|
+
measure(el);
|
|
313
|
+
|
|
314
|
+
const two = [...pointers.values()];
|
|
315
|
+
dragFrom = {
|
|
316
|
+
x: event.clientX,
|
|
317
|
+
y: event.clientY,
|
|
318
|
+
pan: { ...pan.value },
|
|
319
|
+
distance: two.length >= 2 ? Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y) : 0,
|
|
320
|
+
zoom: zoom.value,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Only capture once there is something to drag. At 1x the tile must keep
|
|
324
|
+
// behaving like a button: click selects, double-click goes full screen.
|
|
325
|
+
if (zoom.value > 1 || pointers.size >= 2) el.setPointerCapture?.(event.pointerId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function onPointerMove(event: PointerEvent) {
|
|
329
|
+
if (!canZoom.value || !pointers.has(event.pointerId)) return;
|
|
330
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
331
|
+
|
|
332
|
+
const two = [...pointers.values()];
|
|
333
|
+
if (two.length >= 2) {
|
|
334
|
+
// Pinch. The starting distance is re-anchored on every release, because a
|
|
335
|
+
// second finger lifting mid-pinch otherwise leaves a stale one and the
|
|
336
|
+
// next move jumps.
|
|
337
|
+
const distance = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
338
|
+
if (dragFrom.distance > 0) setZoom((dragFrom.zoom * distance) / dragFrom.distance);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (zoom.value <= 1) return;
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
pan.value = clampPan(
|
|
345
|
+
{
|
|
346
|
+
x: dragFrom.pan.x + (event.clientX - dragFrom.x),
|
|
347
|
+
y: dragFrom.pan.y + (event.clientY - dragFrom.y),
|
|
348
|
+
},
|
|
349
|
+
zoom.value,
|
|
350
|
+
frame.value
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function onPointerEnd(event: PointerEvent) {
|
|
355
|
+
pointers.delete(event.pointerId);
|
|
356
|
+
dragFrom = { ...dragFrom, distance: 0, zoom: zoom.value, pan: { ...pan.value } };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A tile reused for a different camera must not inherit the previous one's
|
|
361
|
+
* magnification, and a camera that stops supporting zoom must not stay stuck
|
|
362
|
+
* in a crop nobody can now reset.
|
|
363
|
+
*/
|
|
364
|
+
watch([() => props.camera?._id, canZoom], () => resetZoom());
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The document arrived. That is ALL this proves — the video service answers
|
|
368
|
+
* with the same player page for a channel that has no camera behind it — so it
|
|
369
|
+
* buys `page-up` and no more. Only `onPlayerMessage` can reach "Live".
|
|
370
|
+
*/
|
|
371
|
+
function onPlayerLoad() {
|
|
372
|
+
clearTimer();
|
|
373
|
+
if (player.value !== "playing") player.value = "page-up";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Rare for a cross-origin frame, but free, and unambiguous when it happens. */
|
|
377
|
+
function onPlayerError() {
|
|
378
|
+
clearTimer();
|
|
379
|
+
player.value = "failed";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* THE ONLY ROUTE TO A GREEN BADGE.
|
|
384
|
+
*
|
|
385
|
+
* Two checks before a message is believed, and both matter: the message must
|
|
386
|
+
* come from the window we embedded (any page can `postMessage` this one), and
|
|
387
|
+
* it must come from the camera's own origin. An unrecognised payload returns
|
|
388
|
+
* `null` and leaves the tile exactly where it was — a message we do not
|
|
389
|
+
* understand must never be able to turn a badge green.
|
|
390
|
+
*/
|
|
391
|
+
function onPlayerMessage(event: MessageEvent) {
|
|
392
|
+
const origin = playerOrigin(props.camera?.host);
|
|
393
|
+
if (!origin || event.origin !== origin) return;
|
|
394
|
+
if (frameEl.value && event.source !== frameEl.value.contentWindow) return;
|
|
395
|
+
|
|
396
|
+
const signal = playerSignal(event.data);
|
|
397
|
+
if (!signal) return;
|
|
398
|
+
|
|
399
|
+
clearTimer();
|
|
400
|
+
if (signal === "playing") {
|
|
401
|
+
player.value = "playing";
|
|
402
|
+
// A picture that arrived once and stopped must not stay green. This is the
|
|
403
|
+
// mobile app's "Live over a nine-second-old still" defect, and the clock is
|
|
404
|
+
// the only thing standing between us and repeating it.
|
|
405
|
+
restartFrameClock();
|
|
406
|
+
} else {
|
|
407
|
+
clearFrameTimer();
|
|
408
|
+
player.value = signal === "stalled" ? "stalled" : "failed";
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function clearTimer() {
|
|
413
|
+
if (timer) {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
timer = null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function clearFrameTimer() {
|
|
420
|
+
if (frameTimer) {
|
|
421
|
+
clearTimeout(frameTimer);
|
|
422
|
+
frameTimer = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function restartFrameClock() {
|
|
427
|
+
clearFrameTimer();
|
|
428
|
+
frameTimer = setTimeout(() => {
|
|
429
|
+
if (player.value === "playing") player.value = "stalled";
|
|
430
|
+
}, LIVE_FRAME_TIMEOUT_MS);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Restart the clock whenever the address changes — a tile reused for a
|
|
435
|
+
* different camera must not inherit the previous one's verdict, in either
|
|
436
|
+
* direction.
|
|
437
|
+
*
|
|
438
|
+
* The timeout only fires while nothing at all has been heard: a page that
|
|
439
|
+
* loaded is already at `page-up` and stays there. Sitting on "Not confirmed"
|
|
440
|
+
* indefinitely is deliberate — declaring a camera dead on a timer, with no
|
|
441
|
+
* evidence, is the same mistake as declaring it live on a timer.
|
|
442
|
+
*/
|
|
443
|
+
watch(
|
|
444
|
+
() => props.camera?.host,
|
|
445
|
+
(host) => {
|
|
446
|
+
clearTimer();
|
|
447
|
+
clearFrameTimer();
|
|
448
|
+
player.value = "loading";
|
|
449
|
+
if (!host) return;
|
|
450
|
+
timer = setTimeout(() => {
|
|
451
|
+
if (player.value === "loading") player.value = "failed";
|
|
452
|
+
}, LIVE_CONNECT_TIMEOUT_MS);
|
|
453
|
+
},
|
|
454
|
+
{ immediate: true }
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
onMounted(() => window.addEventListener("message", onPlayerMessage));
|
|
458
|
+
|
|
459
|
+
onBeforeUnmount(() => {
|
|
460
|
+
window.removeEventListener("message", onPlayerMessage);
|
|
461
|
+
clearTimer();
|
|
462
|
+
clearFrameTimer();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
|
|
466
|
+
async function toggleFullscreen() {
|
|
467
|
+
const el = root.value;
|
|
468
|
+
if (!el) return;
|
|
469
|
+
try {
|
|
470
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
471
|
+
else await el.requestFullscreen();
|
|
472
|
+
} catch {
|
|
473
|
+
// A browser that refuses fullscreen (permissions policy, an iframe, an old
|
|
474
|
+
// Safari) is not an error worth interrupting a supervisor over — the tile
|
|
475
|
+
// keeps working exactly as it did.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
</script>
|
|
479
|
+
|
|
480
|
+
<style scoped>
|
|
481
|
+
.vms-tile {
|
|
482
|
+
position: relative;
|
|
483
|
+
overflow: hidden;
|
|
484
|
+
background: var(--vms-tile, #101418);
|
|
485
|
+
border-radius: var(--vms-radius, 3px);
|
|
486
|
+
outline: none;
|
|
487
|
+
min-height: 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/*
|
|
491
|
+
THE SELECTED TILE, AND WHY IT IS NOW LOUDER.
|
|
492
|
+
|
|
493
|
+
The panel under the wall describes ONE tile. It used to say which by printing
|
|
494
|
+
the camera's name — directly under a tile already captioned with that name —
|
|
495
|
+
because the ring here was too quiet to be doing that job: 2 px of #5b9be0
|
|
496
|
+
disappears against a bright picture, and at nine tiles it was a hairline the
|
|
497
|
+
eye skipped. So the panel was answering "which one?" in words, and the words
|
|
498
|
+
read as the name repeated twice.
|
|
499
|
+
|
|
500
|
+
Two marks now, and between them they hold at 1x1, 2x2 and 3x3:
|
|
501
|
+
|
|
502
|
+
- a **3 px accent ring**, with a 1 px scrim hairline INSIDE it. The hairline is
|
|
503
|
+
what makes the ring survive a white picture — accent-on-white is a weak edge,
|
|
504
|
+
accent-on-near-black is not, so the ring carries its own dark backing rather
|
|
505
|
+
than depending on what the camera happens to be pointing at.
|
|
506
|
+
- the **name chip underlined in the accent**, which is the same mark the
|
|
507
|
+
toolbar already uses for a selected tool (`.vms__tool--on`). At 3x3 the ring
|
|
508
|
+
is 3 px on a ~118 px tile; the underline sits right beside the caption a
|
|
509
|
+
supervisor is already reading, so identification does not depend on
|
|
510
|
+
perceiving 3 px from across a room.
|
|
511
|
+
|
|
512
|
+
INVENTED VALUES, recorded: the ring is 3 px where it was 2 px. Everything else
|
|
513
|
+
is existing — `--vms-accent`, `--vms-scrim-solid`, and the toolbar's 2 px
|
|
514
|
+
underline idiom. No new colour.
|
|
515
|
+
*/
|
|
516
|
+
.vms-tile--focused {
|
|
517
|
+
box-shadow:
|
|
518
|
+
inset 0 0 0 3px var(--vms-accent, #5b9be0),
|
|
519
|
+
inset 0 0 0 4px var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.vms-tile--focused .vms-tile__name {
|
|
523
|
+
box-shadow: inset 0 -2px 0 var(--vms-accent, #5b9be0);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.vms-tile:focus-visible {
|
|
527
|
+
box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.vms-tile--empty {
|
|
531
|
+
background: transparent;
|
|
532
|
+
border: 1px dashed var(--vms-line, #232b36);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/*
|
|
536
|
+
The stage is what the transform is applied to; the tile clips it. No
|
|
537
|
+
transition: a supervisor dragging a magnified picture expects it under their
|
|
538
|
+
pointer, and 150 ms of easing on every move reads as lag, not polish.
|
|
539
|
+
*/
|
|
540
|
+
.vms-tile__stage {
|
|
541
|
+
position: absolute;
|
|
542
|
+
inset: 0;
|
|
543
|
+
transform-origin: center center;
|
|
544
|
+
will-change: transform;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.vms-tile__frame {
|
|
548
|
+
width: 100%;
|
|
549
|
+
height: 100%;
|
|
550
|
+
border: none;
|
|
551
|
+
display: block;
|
|
552
|
+
background: #000;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/*
|
|
556
|
+
Transparent, and over the picture. It is what gives the tile its clicks back
|
|
557
|
+
from the iframe; `touch-action: none` is what lets a pinch be a pinch rather
|
|
558
|
+
than the browser zooming the whole page.
|
|
559
|
+
*/
|
|
560
|
+
.vms-tile__grab {
|
|
561
|
+
position: absolute;
|
|
562
|
+
inset: 0;
|
|
563
|
+
touch-action: none;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.vms-tile__grab--zoomed {
|
|
567
|
+
cursor: grab;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.vms-tile__grab--zoomed:active {
|
|
571
|
+
cursor: grabbing;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.vms-tile__overlay {
|
|
575
|
+
position: absolute;
|
|
576
|
+
inset: 0;
|
|
577
|
+
display: flex;
|
|
578
|
+
align-items: center;
|
|
579
|
+
justify-content: center;
|
|
580
|
+
padding: 10px;
|
|
581
|
+
background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
|
|
582
|
+
pointer-events: none;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.vms-tile__reason {
|
|
586
|
+
margin: 0;
|
|
587
|
+
text-align: center;
|
|
588
|
+
color: var(--vms-text-dim, #94a0b0);
|
|
589
|
+
font-size: 12px;
|
|
590
|
+
line-height: 16px;
|
|
591
|
+
max-width: 32ch;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.vms-tile--dense .vms-tile__reason {
|
|
595
|
+
font-size: 11px;
|
|
596
|
+
line-height: 15px;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.vms-tile__status {
|
|
600
|
+
position: absolute;
|
|
601
|
+
top: 6px;
|
|
602
|
+
right: 6px;
|
|
603
|
+
display: flex;
|
|
604
|
+
align-items: center;
|
|
605
|
+
gap: 5px;
|
|
606
|
+
padding: 2px 6px;
|
|
607
|
+
border-radius: var(--vms-radius, 3px);
|
|
608
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
609
|
+
pointer-events: none;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.vms-tile__dot {
|
|
613
|
+
width: 7px;
|
|
614
|
+
height: 7px;
|
|
615
|
+
border-radius: 50%;
|
|
616
|
+
background: var(--vms-text-faint, #5c6675);
|
|
617
|
+
flex: none;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/* State colours only. The chrome itself stays achromatic on purpose. */
|
|
621
|
+
.vms-tile__dot--live {
|
|
622
|
+
background: #4caf50;
|
|
623
|
+
}
|
|
624
|
+
.vms-tile__dot--connecting {
|
|
625
|
+
background: #fb8c00;
|
|
626
|
+
}
|
|
627
|
+
/*
|
|
628
|
+
NOT VERIFIED is deliberately not amber and deliberately not green. Amber is
|
|
629
|
+
"something is wrong", and nothing is known to be wrong; green is the claim
|
|
630
|
+
being withdrawn. A hollow ring reads as "no answer here", which is exactly
|
|
631
|
+
the fact. #7a8697 measures 5.0:1 on a tile — a dot nobody can see is a state
|
|
632
|
+
nobody knows about.
|
|
633
|
+
*/
|
|
634
|
+
.vms-tile__dot--unverified {
|
|
635
|
+
background: transparent;
|
|
636
|
+
box-shadow: inset 0 0 0 2px #7a8697;
|
|
637
|
+
}
|
|
638
|
+
.vms-tile__dot--no-signal,
|
|
639
|
+
.vms-tile__dot--offline {
|
|
640
|
+
background: #e0241c;
|
|
641
|
+
}
|
|
642
|
+
.vms-tile__dot--unavailable {
|
|
643
|
+
background: var(--vms-text-faint, #5c6675);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.vms-tile__state {
|
|
647
|
+
color: var(--vms-text, #e8ecf1);
|
|
648
|
+
font-size: 11px;
|
|
649
|
+
line-height: 14px;
|
|
650
|
+
white-space: nowrap;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* Nine tiles: smaller and tighter, still a word. */
|
|
654
|
+
.vms-tile--dense .vms-tile__status {
|
|
655
|
+
top: 4px;
|
|
656
|
+
right: 4px;
|
|
657
|
+
gap: 4px;
|
|
658
|
+
padding: 1px 5px;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.vms-tile--dense .vms-tile__state {
|
|
662
|
+
font-size: 10px;
|
|
663
|
+
line-height: 13px;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/*
|
|
667
|
+
`wrap` is what keeps the promise that the name and the recorder line both
|
|
668
|
+
survive a narrow tile: on a cramped nine-tile wall the health phrase drops to
|
|
669
|
+
its own line instead of squeezing the name into an ellipsis.
|
|
670
|
+
*/
|
|
671
|
+
.vms-tile__chrome {
|
|
672
|
+
position: absolute;
|
|
673
|
+
left: 6px;
|
|
674
|
+
right: 6px;
|
|
675
|
+
bottom: 5px;
|
|
676
|
+
display: flex;
|
|
677
|
+
align-items: center;
|
|
678
|
+
flex-wrap: wrap;
|
|
679
|
+
gap: 3px 6px;
|
|
680
|
+
pointer-events: none;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.vms-tile--dense .vms-tile__chrome {
|
|
684
|
+
left: 4px;
|
|
685
|
+
right: 4px;
|
|
686
|
+
bottom: 4px;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/*
|
|
690
|
+
The RECORDER's state, and deliberately quieter than the name and the badge:
|
|
691
|
+
it is a second-order fact a supervisor reads when something looks wrong, not
|
|
692
|
+
something that should compete with the picture every second it is fine.
|
|
693
|
+
*/
|
|
694
|
+
.vms-tile__health {
|
|
695
|
+
display: inline-flex;
|
|
696
|
+
align-items: center;
|
|
697
|
+
gap: 5px;
|
|
698
|
+
flex: 0 1 auto;
|
|
699
|
+
min-width: 0;
|
|
700
|
+
padding: 1px 6px;
|
|
701
|
+
border-radius: var(--vms-radius, 3px);
|
|
702
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
703
|
+
color: var(--vms-text-dim, #94a0b0);
|
|
704
|
+
font-size: 11px;
|
|
705
|
+
line-height: 15px;
|
|
706
|
+
white-space: nowrap;
|
|
707
|
+
overflow: hidden;
|
|
708
|
+
text-overflow: ellipsis;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.vms-tile--dense .vms-tile__health {
|
|
712
|
+
font-size: 10px;
|
|
713
|
+
line-height: 14px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.vms-tile__dot--h-ok {
|
|
717
|
+
background: #4caf50;
|
|
718
|
+
}
|
|
719
|
+
.vms-tile__dot--h-warn {
|
|
720
|
+
background: #fb8c00;
|
|
721
|
+
}
|
|
722
|
+
.vms-tile__dot--h-down {
|
|
723
|
+
background: #e0241c;
|
|
724
|
+
}
|
|
725
|
+
.vms-tile__dot--h-unknown {
|
|
726
|
+
background: var(--vms-text-faint, #5c6675);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/* --------------------------------------------------------------- the zoom */
|
|
730
|
+
|
|
731
|
+
.vms-tile__zoom {
|
|
732
|
+
display: inline-flex;
|
|
733
|
+
align-items: center;
|
|
734
|
+
gap: 6px;
|
|
735
|
+
/* Pushed to the far end of the chrome row, opposite the name. */
|
|
736
|
+
margin-left: auto;
|
|
737
|
+
flex: none;
|
|
738
|
+
padding: 1px 6px;
|
|
739
|
+
border-radius: var(--vms-radius, 3px);
|
|
740
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
741
|
+
font-size: 11px;
|
|
742
|
+
line-height: 15px;
|
|
743
|
+
/* The row is click-through; this one control is not. */
|
|
744
|
+
pointer-events: auto;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.vms-tile__zoom-level {
|
|
748
|
+
color: var(--vms-text, #e8ecf1);
|
|
749
|
+
font-variant-numeric: tabular-nums;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.vms-tile__zoom-reset {
|
|
753
|
+
background: none;
|
|
754
|
+
border: none;
|
|
755
|
+
padding: 2px 2px;
|
|
756
|
+
color: var(--vms-accent, #5b9be0);
|
|
757
|
+
font-size: 11px;
|
|
758
|
+
line-height: 15px;
|
|
759
|
+
cursor: pointer;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.vms-tile__zoom-reset:focus-visible {
|
|
763
|
+
outline: 2px solid var(--vms-accent, #5b9be0);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.vms-tile__name {
|
|
767
|
+
color: var(--vms-text, #e8ecf1);
|
|
768
|
+
font-size: 12px;
|
|
769
|
+
line-height: 16px;
|
|
770
|
+
font-weight: 600;
|
|
771
|
+
padding: 1px 6px;
|
|
772
|
+
border-radius: var(--vms-radius, 3px);
|
|
773
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
774
|
+
overflow: hidden;
|
|
775
|
+
text-overflow: ellipsis;
|
|
776
|
+
white-space: nowrap;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.vms-tile--dense .vms-tile__name {
|
|
780
|
+
font-size: 11px;
|
|
781
|
+
line-height: 15px;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.vms-tile__expand {
|
|
785
|
+
position: absolute;
|
|
786
|
+
top: 6px;
|
|
787
|
+
left: 6px;
|
|
788
|
+
display: flex;
|
|
789
|
+
align-items: center;
|
|
790
|
+
justify-content: center;
|
|
791
|
+
width: 24px;
|
|
792
|
+
height: 24px;
|
|
793
|
+
border-radius: var(--vms-radius, 3px);
|
|
794
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
795
|
+
color: var(--vms-text, #e8ecf1);
|
|
796
|
+
border: none;
|
|
797
|
+
cursor: pointer;
|
|
798
|
+
opacity: 0;
|
|
799
|
+
transition: opacity 120ms ease;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/* The control is always THERE; it only stops competing with the picture. */
|
|
803
|
+
.vms-tile:hover .vms-tile__expand,
|
|
804
|
+
.vms-tile:focus-within .vms-tile__expand {
|
|
805
|
+
opacity: 1;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
.vms-tile__expand:focus-visible {
|
|
809
|
+
opacity: 1;
|
|
810
|
+
outline: 2px solid var(--vms-accent, #5b9be0);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
|
|
814
|
+
.vms-tile:fullscreen {
|
|
815
|
+
border-radius: 0;
|
|
816
|
+
background: #000;
|
|
817
|
+
}
|
|
818
|
+
</style>
|