@7365admin1/layer-common 3.2.1-staging.63 → 3.2.1-staging.65
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 +326 -16
- package/components/CameraWallTile.vue +478 -29
- package/components/HidAccessLogDashboard.vue +44 -20
- package/composables/useSiteSettings.ts +22 -0
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +266 -10
- package/utils/camera-wall.ts +554 -47
|
@@ -13,16 +13,49 @@
|
|
|
13
13
|
The picture, when there is one to show. This is an embed of the video
|
|
14
14
|
service's own player page: the stored address IS the live view, and there
|
|
15
15
|
is no other transport for live web video today.
|
|
16
|
+
|
|
17
|
+
The STAGE around it is what zooms: the transform is on our own element,
|
|
18
|
+
so magnifying the picture needs nothing from inside that page and reaches
|
|
19
|
+
into no other origin.
|
|
20
|
+
-->
|
|
21
|
+
<div
|
|
22
|
+
v-if="camera && view.showPlayer"
|
|
23
|
+
class="vms-tile__stage"
|
|
24
|
+
:style="stageStyle"
|
|
25
|
+
>
|
|
26
|
+
<iframe
|
|
27
|
+
ref="frameEl"
|
|
28
|
+
:key="camera.host"
|
|
29
|
+
:src="camera.host"
|
|
30
|
+
class="vms-tile__frame"
|
|
31
|
+
:title="`Live view of ${camera.name || 'camera'}`"
|
|
32
|
+
allowfullscreen
|
|
33
|
+
referrerpolicy="no-referrer"
|
|
34
|
+
@load="onPlayerLoad"
|
|
35
|
+
@error="onPlayerError"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!--
|
|
40
|
+
THE LAYER THAT MAKES THE TILE A TILE AGAIN.
|
|
41
|
+
|
|
42
|
+
An iframe swallows every pointer event that lands on it, so before this
|
|
43
|
+
existed a click on a tile that was actually showing a picture never
|
|
44
|
+
reached us: selecting a camera only worked on the tiles with no video in
|
|
45
|
+
them, which is the opposite of useful. This transparent layer sits over
|
|
46
|
+
the picture so a click selects, a double-click goes full screen, and —
|
|
47
|
+
when the server says this camera supports it — a wheel zooms and a drag
|
|
48
|
+
pans. The player page underneath has no controls of its own to lose.
|
|
16
49
|
-->
|
|
17
|
-
<
|
|
50
|
+
<div
|
|
18
51
|
v-if="camera && view.showPlayer"
|
|
19
|
-
|
|
20
|
-
:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@
|
|
52
|
+
class="vms-tile__grab"
|
|
53
|
+
:class="{ 'vms-tile__grab--zoomed': zoom > 1 }"
|
|
54
|
+
@wheel="onWheel"
|
|
55
|
+
@pointerdown="onPointerDown"
|
|
56
|
+
@pointermove="onPointerMove"
|
|
57
|
+
@pointerup="onPointerEnd"
|
|
58
|
+
@pointercancel="onPointerEnd"
|
|
26
59
|
/>
|
|
27
60
|
|
|
28
61
|
<!--
|
|
@@ -35,20 +68,60 @@
|
|
|
35
68
|
</div>
|
|
36
69
|
|
|
37
70
|
<!-- Chrome sits OVER the picture, never around it. -->
|
|
38
|
-
|
|
71
|
+
<!--
|
|
72
|
+
THE STATUS WORD IS NEVER DROPPED, AT ANY LAYOUT.
|
|
73
|
+
|
|
74
|
+
It used to be: at nine tiles the badge became a bare 7 px dot, on the
|
|
75
|
+
argument that the word was unreadable at wall-scanning distance. That is
|
|
76
|
+
a phone's argument. Nine tiles on a desktop wall is the layout a
|
|
77
|
+
supervisor watches from furthest away and the one where a dying camera is
|
|
78
|
+
most likely to go unnoticed, so it is the last place to remove the only
|
|
79
|
+
words that say whether a camera is alive. The type shrinks; the fact
|
|
80
|
+
stays.
|
|
81
|
+
-->
|
|
82
|
+
<div v-if="camera" class="vms-tile__status" :title="view.note || undefined">
|
|
39
83
|
<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>
|
|
84
|
+
<span class="vms-tile__state">{{ view.label }}</span>
|
|
47
85
|
</div>
|
|
48
86
|
|
|
49
87
|
<div v-if="camera" class="vms-tile__chrome">
|
|
50
88
|
<!-- The NAME wins the fight for room: it is the tile's entire job. -->
|
|
51
89
|
<span class="vms-tile__name">{{ camera.name || camera._id }}</span>
|
|
90
|
+
<!--
|
|
91
|
+
RULE 6: the RECORDER's state, kept apart from the badge above, which is
|
|
92
|
+
about the player page. At nine tiles it keeps its phrase and loses only
|
|
93
|
+
the timestamp — a dropped recorder is not a detail to hide on the
|
|
94
|
+
busiest layout.
|
|
95
|
+
-->
|
|
96
|
+
<span
|
|
97
|
+
v-if="healthLine"
|
|
98
|
+
class="vms-tile__health"
|
|
99
|
+
:title="healthTitle"
|
|
100
|
+
>
|
|
101
|
+
<span class="vms-tile__dot" :class="`vms-tile__dot--h-${recorder.tone}`" />
|
|
102
|
+
{{ healthLine }}
|
|
103
|
+
</span>
|
|
104
|
+
|
|
105
|
+
<!--
|
|
106
|
+
THE ZOOM READOUT AND THE WAY BACK.
|
|
107
|
+
|
|
108
|
+
Only drawn once the picture is actually magnified. A supervisor who has
|
|
109
|
+
zoomed into a corner needs one obvious control out, and needs to be
|
|
110
|
+
able to tell a magnified picture from a camera that has been moved —
|
|
111
|
+
hence the "2.0x" reading rather than a bare arrow. In the same row as
|
|
112
|
+
the name, so it can never sit on top of it.
|
|
113
|
+
-->
|
|
114
|
+
<span v-if="zoom > 1" class="vms-tile__zoom">
|
|
115
|
+
<span class="vms-tile__zoom-level">{{ zoom.toFixed(1) }}x</span>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="vms-tile__zoom-reset"
|
|
119
|
+
title="Back to the whole picture"
|
|
120
|
+
@click.stop="resetZoom"
|
|
121
|
+
>
|
|
122
|
+
Reset
|
|
123
|
+
</button>
|
|
124
|
+
</span>
|
|
52
125
|
</div>
|
|
53
126
|
|
|
54
127
|
<button
|
|
@@ -73,13 +146,22 @@
|
|
|
73
146
|
</template>
|
|
74
147
|
|
|
75
148
|
<script setup lang="ts">
|
|
76
|
-
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
149
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
77
150
|
|
|
78
151
|
import {
|
|
152
|
+
capabilityView,
|
|
153
|
+
clampPan,
|
|
154
|
+
clampZoom,
|
|
155
|
+
healthView,
|
|
79
156
|
LIVE_CONNECT_TIMEOUT_MS,
|
|
157
|
+
LIVE_FRAME_TIMEOUT_MS,
|
|
158
|
+
playerOrigin,
|
|
159
|
+
playerSignal,
|
|
80
160
|
tileView,
|
|
161
|
+
zoomStep,
|
|
81
162
|
type TPlayerState,
|
|
82
163
|
type TWallCamera,
|
|
164
|
+
type TWallCameraHealth,
|
|
83
165
|
} from "../utils/camera-wall";
|
|
84
166
|
|
|
85
167
|
/**
|
|
@@ -89,16 +171,26 @@ import {
|
|
|
89
171
|
* left here is the picture, the chrome over it, and the one thing only a
|
|
90
172
|
* browser can know — whether the player page ever loaded.
|
|
91
173
|
*
|
|
92
|
-
* ## The ceiling
|
|
174
|
+
* ## The ceiling, and what the badge is now allowed to claim
|
|
93
175
|
*
|
|
94
176
|
* 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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* service
|
|
177
|
+
* the page belongs to the video service, not to us, and reaching into another
|
|
178
|
+
* origin's document is blocked by the browser — correctly.
|
|
179
|
+
*
|
|
180
|
+
* That ceiling has not moved. What has changed is that the badge no longer
|
|
181
|
+
* pretends otherwise. A loaded page earns **"Not verified"**, not "Live"; only
|
|
182
|
+
* a frame the video service itself reports over `postMessage` earns "Live".
|
|
183
|
+
* The service sends nothing today, so every tile on this estate sits at "Not
|
|
184
|
+
* verified" — which is the true answer. It went in because on the live wall two
|
|
185
|
+
* cameras rendered a blank rectangle under a green "Live" badge, which is the
|
|
186
|
+
* one thing a monitoring screen must never do.
|
|
187
|
+
*
|
|
188
|
+
* The listener below is inert until the video service emits. The change needed
|
|
189
|
+
* there is small — jsmpeg already calls back on every decoded frame, so
|
|
190
|
+
* `parent.postMessage({ event: "frame" }, "*")` from its `onVideoDecode` and
|
|
191
|
+
* `{ event: "stalled" }` from `onStalled` is the whole of it — but it lives in
|
|
192
|
+
* another repository and another team's deployment, and is written up in the
|
|
193
|
+
* PR rather than done here.
|
|
102
194
|
*/
|
|
103
195
|
|
|
104
196
|
const props = withDefaults(
|
|
@@ -109,23 +201,208 @@ const props = withDefaults(
|
|
|
109
201
|
/** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
|
|
110
202
|
online?: boolean;
|
|
111
203
|
focused?: boolean;
|
|
204
|
+
/** The recorder's own state, from the health sweep. Separate fact — rule 6. */
|
|
205
|
+
health?: TWallCameraHealth | null;
|
|
206
|
+
/** Recomputed by the wall on its own clock so "4 min ago" does not freeze. */
|
|
207
|
+
now?: number;
|
|
112
208
|
}>(),
|
|
113
|
-
{ dense: false, online: true, focused: false }
|
|
209
|
+
{ dense: false, online: true, focused: false, health: null, now: 0 }
|
|
114
210
|
);
|
|
115
211
|
|
|
116
212
|
defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
|
|
117
213
|
|
|
118
214
|
const root = ref<HTMLElement | null>(null);
|
|
215
|
+
const frameEl = ref<HTMLIFrameElement | null>(null);
|
|
119
216
|
const player = ref<TPlayerState>("loading");
|
|
120
217
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
218
|
+
let frameTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
219
|
|
|
122
220
|
const view = computed(() =>
|
|
123
221
|
tileView(props.camera, { online: props.online, player: player.value })
|
|
124
222
|
);
|
|
125
223
|
|
|
224
|
+
/* ------------------------------------------------------------------ health */
|
|
225
|
+
|
|
226
|
+
const recorder = computed(() => healthView(props.health, props.now || Date.now()));
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One line, and the last-picture time only when there is one. A tile that has
|
|
230
|
+
* never served a picture must not read as "last picture: never" — it has its
|
|
231
|
+
* own reason across the tile already, and repeating it small and grey is noise.
|
|
232
|
+
*/
|
|
233
|
+
const healthLine = computed(() => {
|
|
234
|
+
if (!props.health) return "";
|
|
235
|
+
const { label, lastFrame } = recorder.value;
|
|
236
|
+
// Nine tiles: the phrase stays, the timestamp goes. "Recorder not responding"
|
|
237
|
+
// is the fact; "· 4 min ago" is the detail, and detail is what a small tile
|
|
238
|
+
// gives up.
|
|
239
|
+
return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/** Everything the line had no room for, on hover. */
|
|
243
|
+
const healthTitle = computed(() =>
|
|
244
|
+
[recorder.value.detail, recorder.value.deviceDetail].filter(Boolean).join(" ")
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
/* -------------------------------------------------------------------- zoom */
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* DIGITAL ZOOM — armed by the SERVER, not by this component.
|
|
251
|
+
*
|
|
252
|
+
* `digitalZoom` is a capability like any other, and `unknown` counts as no: a
|
|
253
|
+
* browser talking to an API that predates the descriptor gets no zoom rather
|
|
254
|
+
* than a gesture that silently does nothing. Nothing here contacts a device —
|
|
255
|
+
* this crops and enlarges the picture already on the screen — but the rule is
|
|
256
|
+
* the rule, and honouring it is what keeps zoom and "Move camera" visibly
|
|
257
|
+
* different things.
|
|
258
|
+
*/
|
|
259
|
+
const canZoom = computed(
|
|
260
|
+
() => view.value.showPlayer && capabilityView(props.camera, "digitalZoom").available
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const zoom = ref(1);
|
|
264
|
+
const pan = ref({ x: 0, y: 0 });
|
|
265
|
+
const frame = ref({ width: 0, height: 0 });
|
|
266
|
+
|
|
267
|
+
/** Live pointers, by id: two of them is a pinch, one is a drag. */
|
|
268
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
269
|
+
let dragFrom = { x: 0, y: 0, pan: { x: 0, y: 0 }, distance: 0, zoom: 1 };
|
|
270
|
+
|
|
271
|
+
const stageStyle = computed(() => ({
|
|
272
|
+
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
function resetZoom() {
|
|
276
|
+
zoom.value = 1;
|
|
277
|
+
pan.value = { x: 0, y: 0 };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function measure(el: HTMLElement | null) {
|
|
281
|
+
if (!el) return;
|
|
282
|
+
const box = el.getBoundingClientRect();
|
|
283
|
+
frame.value = { width: box.width, height: box.height };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function setZoom(next: number) {
|
|
287
|
+
const scale = clampZoom(next);
|
|
288
|
+
zoom.value = scale;
|
|
289
|
+
pan.value = clampPan(pan.value, scale, frame.value);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function onWheel(event: WheelEvent) {
|
|
293
|
+
if (!canZoom.value) return;
|
|
294
|
+
// At 1x, scrolling DOWN is somebody scrolling the page and must stay that
|
|
295
|
+
// way — a wall two thirds of the way down a page that traps the wheel is
|
|
296
|
+
// a page you cannot get past.
|
|
297
|
+
if (zoom.value <= 1 && event.deltaY > 0) return;
|
|
298
|
+
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
measure(event.currentTarget as HTMLElement);
|
|
301
|
+
setZoom(zoomStep(zoom.value, event.deltaY));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function onPointerDown(event: PointerEvent) {
|
|
305
|
+
if (!canZoom.value) return;
|
|
306
|
+
const el = event.currentTarget as HTMLElement;
|
|
307
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
308
|
+
measure(el);
|
|
309
|
+
|
|
310
|
+
const two = [...pointers.values()];
|
|
311
|
+
dragFrom = {
|
|
312
|
+
x: event.clientX,
|
|
313
|
+
y: event.clientY,
|
|
314
|
+
pan: { ...pan.value },
|
|
315
|
+
distance: two.length >= 2 ? Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y) : 0,
|
|
316
|
+
zoom: zoom.value,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Only capture once there is something to drag. At 1x the tile must keep
|
|
320
|
+
// behaving like a button: click selects, double-click goes full screen.
|
|
321
|
+
if (zoom.value > 1 || pointers.size >= 2) el.setPointerCapture?.(event.pointerId);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function onPointerMove(event: PointerEvent) {
|
|
325
|
+
if (!canZoom.value || !pointers.has(event.pointerId)) return;
|
|
326
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
327
|
+
|
|
328
|
+
const two = [...pointers.values()];
|
|
329
|
+
if (two.length >= 2) {
|
|
330
|
+
// Pinch. The starting distance is re-anchored on every release, because a
|
|
331
|
+
// second finger lifting mid-pinch otherwise leaves a stale one and the
|
|
332
|
+
// next move jumps.
|
|
333
|
+
const distance = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
334
|
+
if (dragFrom.distance > 0) setZoom((dragFrom.zoom * distance) / dragFrom.distance);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (zoom.value <= 1) return;
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
pan.value = clampPan(
|
|
341
|
+
{
|
|
342
|
+
x: dragFrom.pan.x + (event.clientX - dragFrom.x),
|
|
343
|
+
y: dragFrom.pan.y + (event.clientY - dragFrom.y),
|
|
344
|
+
},
|
|
345
|
+
zoom.value,
|
|
346
|
+
frame.value
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function onPointerEnd(event: PointerEvent) {
|
|
351
|
+
pointers.delete(event.pointerId);
|
|
352
|
+
dragFrom = { ...dragFrom, distance: 0, zoom: zoom.value, pan: { ...pan.value } };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A tile reused for a different camera must not inherit the previous one's
|
|
357
|
+
* magnification, and a camera that stops supporting zoom must not stay stuck
|
|
358
|
+
* in a crop nobody can now reset.
|
|
359
|
+
*/
|
|
360
|
+
watch([() => props.camera?._id, canZoom], () => resetZoom());
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The document arrived. That is ALL this proves — the video service answers
|
|
364
|
+
* with the same player page for a channel that has no camera behind it — so it
|
|
365
|
+
* buys `page-up` and no more. Only `onPlayerMessage` can reach "Live".
|
|
366
|
+
*/
|
|
126
367
|
function onPlayerLoad() {
|
|
127
|
-
player.value = "ready";
|
|
128
368
|
clearTimer();
|
|
369
|
+
if (player.value !== "playing") player.value = "page-up";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Rare for a cross-origin frame, but free, and unambiguous when it happens. */
|
|
373
|
+
function onPlayerError() {
|
|
374
|
+
clearTimer();
|
|
375
|
+
player.value = "failed";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* THE ONLY ROUTE TO A GREEN BADGE.
|
|
380
|
+
*
|
|
381
|
+
* Two checks before a message is believed, and both matter: the message must
|
|
382
|
+
* come from the window we embedded (any page can `postMessage` this one), and
|
|
383
|
+
* it must come from the camera's own origin. An unrecognised payload returns
|
|
384
|
+
* `null` and leaves the tile exactly where it was — a message we do not
|
|
385
|
+
* understand must never be able to turn a badge green.
|
|
386
|
+
*/
|
|
387
|
+
function onPlayerMessage(event: MessageEvent) {
|
|
388
|
+
const origin = playerOrigin(props.camera?.host);
|
|
389
|
+
if (!origin || event.origin !== origin) return;
|
|
390
|
+
if (frameEl.value && event.source !== frameEl.value.contentWindow) return;
|
|
391
|
+
|
|
392
|
+
const signal = playerSignal(event.data);
|
|
393
|
+
if (!signal) return;
|
|
394
|
+
|
|
395
|
+
clearTimer();
|
|
396
|
+
if (signal === "playing") {
|
|
397
|
+
player.value = "playing";
|
|
398
|
+
// A picture that arrived once and stopped must not stay green. This is the
|
|
399
|
+
// mobile app's "Live over a nine-second-old still" defect, and the clock is
|
|
400
|
+
// the only thing standing between us and repeating it.
|
|
401
|
+
restartFrameClock();
|
|
402
|
+
} else {
|
|
403
|
+
clearFrameTimer();
|
|
404
|
+
player.value = signal === "stalled" ? "stalled" : "failed";
|
|
405
|
+
}
|
|
129
406
|
}
|
|
130
407
|
|
|
131
408
|
function clearTimer() {
|
|
@@ -135,25 +412,51 @@ function clearTimer() {
|
|
|
135
412
|
}
|
|
136
413
|
}
|
|
137
414
|
|
|
415
|
+
function clearFrameTimer() {
|
|
416
|
+
if (frameTimer) {
|
|
417
|
+
clearTimeout(frameTimer);
|
|
418
|
+
frameTimer = null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function restartFrameClock() {
|
|
423
|
+
clearFrameTimer();
|
|
424
|
+
frameTimer = setTimeout(() => {
|
|
425
|
+
if (player.value === "playing") player.value = "stalled";
|
|
426
|
+
}, LIVE_FRAME_TIMEOUT_MS);
|
|
427
|
+
}
|
|
428
|
+
|
|
138
429
|
/**
|
|
139
430
|
* Restart the clock whenever the address changes — a tile reused for a
|
|
140
431
|
* different camera must not inherit the previous one's verdict, in either
|
|
141
432
|
* direction.
|
|
433
|
+
*
|
|
434
|
+
* The timeout only fires while nothing at all has been heard: a page that
|
|
435
|
+
* loaded is already at `page-up` and stays there. Sitting on "Not verified"
|
|
436
|
+
* indefinitely is deliberate — declaring a camera dead on a timer, with no
|
|
437
|
+
* evidence, is the same mistake as declaring it live on a timer.
|
|
142
438
|
*/
|
|
143
439
|
watch(
|
|
144
440
|
() => props.camera?.host,
|
|
145
441
|
(host) => {
|
|
146
442
|
clearTimer();
|
|
443
|
+
clearFrameTimer();
|
|
147
444
|
player.value = "loading";
|
|
148
445
|
if (!host) return;
|
|
149
446
|
timer = setTimeout(() => {
|
|
150
|
-
if (player.value === "loading") player.value = "
|
|
447
|
+
if (player.value === "loading") player.value = "failed";
|
|
151
448
|
}, LIVE_CONNECT_TIMEOUT_MS);
|
|
152
449
|
},
|
|
153
450
|
{ immediate: true }
|
|
154
451
|
);
|
|
155
452
|
|
|
156
|
-
|
|
453
|
+
onMounted(() => window.addEventListener("message", onPlayerMessage));
|
|
454
|
+
|
|
455
|
+
onBeforeUnmount(() => {
|
|
456
|
+
window.removeEventListener("message", onPlayerMessage);
|
|
457
|
+
clearTimer();
|
|
458
|
+
clearFrameTimer();
|
|
459
|
+
});
|
|
157
460
|
|
|
158
461
|
/** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
|
|
159
462
|
async function toggleFullscreen() {
|
|
@@ -193,6 +496,18 @@ async function toggleFullscreen() {
|
|
|
193
496
|
border: 1px dashed var(--vms-line, #232b36);
|
|
194
497
|
}
|
|
195
498
|
|
|
499
|
+
/*
|
|
500
|
+
The stage is what the transform is applied to; the tile clips it. No
|
|
501
|
+
transition: a supervisor dragging a magnified picture expects it under their
|
|
502
|
+
pointer, and 150 ms of easing on every move reads as lag, not polish.
|
|
503
|
+
*/
|
|
504
|
+
.vms-tile__stage {
|
|
505
|
+
position: absolute;
|
|
506
|
+
inset: 0;
|
|
507
|
+
transform-origin: center center;
|
|
508
|
+
will-change: transform;
|
|
509
|
+
}
|
|
510
|
+
|
|
196
511
|
.vms-tile__frame {
|
|
197
512
|
width: 100%;
|
|
198
513
|
height: 100%;
|
|
@@ -201,6 +516,25 @@ async function toggleFullscreen() {
|
|
|
201
516
|
background: #000;
|
|
202
517
|
}
|
|
203
518
|
|
|
519
|
+
/*
|
|
520
|
+
Transparent, and over the picture. It is what gives the tile its clicks back
|
|
521
|
+
from the iframe; `touch-action: none` is what lets a pinch be a pinch rather
|
|
522
|
+
than the browser zooming the whole page.
|
|
523
|
+
*/
|
|
524
|
+
.vms-tile__grab {
|
|
525
|
+
position: absolute;
|
|
526
|
+
inset: 0;
|
|
527
|
+
touch-action: none;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.vms-tile__grab--zoomed {
|
|
531
|
+
cursor: grab;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.vms-tile__grab--zoomed:active {
|
|
535
|
+
cursor: grabbing;
|
|
536
|
+
}
|
|
537
|
+
|
|
204
538
|
.vms-tile__overlay {
|
|
205
539
|
position: absolute;
|
|
206
540
|
inset: 0;
|
|
@@ -254,6 +588,17 @@ async function toggleFullscreen() {
|
|
|
254
588
|
.vms-tile__dot--connecting {
|
|
255
589
|
background: #fb8c00;
|
|
256
590
|
}
|
|
591
|
+
/*
|
|
592
|
+
NOT VERIFIED is deliberately not amber and deliberately not green. Amber is
|
|
593
|
+
"something is wrong", and nothing is known to be wrong; green is the claim
|
|
594
|
+
being withdrawn. A hollow ring reads as "no answer here", which is exactly
|
|
595
|
+
the fact. #7a8697 measures 5.0:1 on a tile — a dot nobody can see is a state
|
|
596
|
+
nobody knows about.
|
|
597
|
+
*/
|
|
598
|
+
.vms-tile__dot--unverified {
|
|
599
|
+
background: transparent;
|
|
600
|
+
box-shadow: inset 0 0 0 2px #7a8697;
|
|
601
|
+
}
|
|
257
602
|
.vms-tile__dot--no-signal,
|
|
258
603
|
.vms-tile__dot--offline {
|
|
259
604
|
background: #e0241c;
|
|
@@ -269,15 +614,119 @@ async function toggleFullscreen() {
|
|
|
269
614
|
white-space: nowrap;
|
|
270
615
|
}
|
|
271
616
|
|
|
617
|
+
/* Nine tiles: smaller and tighter, still a word. */
|
|
618
|
+
.vms-tile--dense .vms-tile__status {
|
|
619
|
+
top: 4px;
|
|
620
|
+
right: 4px;
|
|
621
|
+
gap: 4px;
|
|
622
|
+
padding: 1px 5px;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.vms-tile--dense .vms-tile__state {
|
|
626
|
+
font-size: 10px;
|
|
627
|
+
line-height: 13px;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/*
|
|
631
|
+
`wrap` is what keeps the promise that the name and the recorder line both
|
|
632
|
+
survive a narrow tile: on a cramped nine-tile wall the health phrase drops to
|
|
633
|
+
its own line instead of squeezing the name into an ellipsis.
|
|
634
|
+
*/
|
|
272
635
|
.vms-tile__chrome {
|
|
273
636
|
position: absolute;
|
|
274
637
|
left: 6px;
|
|
275
638
|
right: 6px;
|
|
276
639
|
bottom: 5px;
|
|
277
640
|
display: flex;
|
|
641
|
+
align-items: center;
|
|
642
|
+
flex-wrap: wrap;
|
|
643
|
+
gap: 3px 6px;
|
|
278
644
|
pointer-events: none;
|
|
279
645
|
}
|
|
280
646
|
|
|
647
|
+
.vms-tile--dense .vms-tile__chrome {
|
|
648
|
+
left: 4px;
|
|
649
|
+
right: 4px;
|
|
650
|
+
bottom: 4px;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/*
|
|
654
|
+
The RECORDER's state, and deliberately quieter than the name and the badge:
|
|
655
|
+
it is a second-order fact a supervisor reads when something looks wrong, not
|
|
656
|
+
something that should compete with the picture every second it is fine.
|
|
657
|
+
*/
|
|
658
|
+
.vms-tile__health {
|
|
659
|
+
display: inline-flex;
|
|
660
|
+
align-items: center;
|
|
661
|
+
gap: 5px;
|
|
662
|
+
flex: 0 1 auto;
|
|
663
|
+
min-width: 0;
|
|
664
|
+
padding: 1px 6px;
|
|
665
|
+
border-radius: var(--vms-radius, 3px);
|
|
666
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
667
|
+
color: var(--vms-text-dim, #94a0b0);
|
|
668
|
+
font-size: 11px;
|
|
669
|
+
line-height: 15px;
|
|
670
|
+
white-space: nowrap;
|
|
671
|
+
overflow: hidden;
|
|
672
|
+
text-overflow: ellipsis;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.vms-tile--dense .vms-tile__health {
|
|
676
|
+
font-size: 10px;
|
|
677
|
+
line-height: 14px;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.vms-tile__dot--h-ok {
|
|
681
|
+
background: #4caf50;
|
|
682
|
+
}
|
|
683
|
+
.vms-tile__dot--h-warn {
|
|
684
|
+
background: #fb8c00;
|
|
685
|
+
}
|
|
686
|
+
.vms-tile__dot--h-down {
|
|
687
|
+
background: #e0241c;
|
|
688
|
+
}
|
|
689
|
+
.vms-tile__dot--h-unknown {
|
|
690
|
+
background: var(--vms-text-faint, #5c6675);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* --------------------------------------------------------------- the zoom */
|
|
694
|
+
|
|
695
|
+
.vms-tile__zoom {
|
|
696
|
+
display: inline-flex;
|
|
697
|
+
align-items: center;
|
|
698
|
+
gap: 6px;
|
|
699
|
+
/* Pushed to the far end of the chrome row, opposite the name. */
|
|
700
|
+
margin-left: auto;
|
|
701
|
+
flex: none;
|
|
702
|
+
padding: 1px 6px;
|
|
703
|
+
border-radius: var(--vms-radius, 3px);
|
|
704
|
+
background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
|
|
705
|
+
font-size: 11px;
|
|
706
|
+
line-height: 15px;
|
|
707
|
+
/* The row is click-through; this one control is not. */
|
|
708
|
+
pointer-events: auto;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.vms-tile__zoom-level {
|
|
712
|
+
color: var(--vms-text, #e8ecf1);
|
|
713
|
+
font-variant-numeric: tabular-nums;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.vms-tile__zoom-reset {
|
|
717
|
+
background: none;
|
|
718
|
+
border: none;
|
|
719
|
+
padding: 2px 2px;
|
|
720
|
+
color: var(--vms-accent, #5b9be0);
|
|
721
|
+
font-size: 11px;
|
|
722
|
+
line-height: 15px;
|
|
723
|
+
cursor: pointer;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.vms-tile__zoom-reset:focus-visible {
|
|
727
|
+
outline: 2px solid var(--vms-accent, #5b9be0);
|
|
728
|
+
}
|
|
729
|
+
|
|
281
730
|
.vms-tile__name {
|
|
282
731
|
color: var(--vms-text, #e8ecf1);
|
|
283
732
|
font-size: 12px;
|