@7365admin1/layer-common 3.2.1-staging.64 → 3.2.1-staging.66
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/composables/useSiteSettings.ts +22 -0
- package/package.json +1 -1
- package/plugins/vuetify.ts +106 -9
- package/utils/camera-wall.test.ts +266 -10
- package/utils/camera-wall.ts +554 -47
package/utils/camera-wall.ts
CHANGED
|
@@ -21,9 +21,20 @@
|
|
|
21
21
|
* 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
|
|
22
22
|
* sentence shown is the SERVER'S own sentence, never rewritten here, so a
|
|
23
23
|
* camera cannot explain itself two different ways on two screens.
|
|
24
|
-
* 4. **"Live" means a picture is arriving**, not "we asked for one"
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* 4. **"Live" means a picture is arriving**, not "we asked for one", and not
|
|
25
|
+
* "the page that would show one loaded". Those are three different claims
|
|
26
|
+
* and the tile used to make the strongest one on the evidence for the
|
|
27
|
+
* weakest. See the long note above `tileView` — this rule was being broken
|
|
28
|
+
* in production and a supervisor was being told a blank tile was live.
|
|
29
|
+
* 5. **A switched-off control also says what would switch it on, and who to
|
|
30
|
+
* ask.** The server's sentence states the fact; it deliberately does not name
|
|
31
|
+
* a next step, because a server has no idea who the reader can go and talk
|
|
32
|
+
* to. So the next step is a SECOND line, owned here and keyed by the
|
|
33
|
+
* server's own reason CODE — rule 3 is intact, nothing is rewritten.
|
|
34
|
+
* 6. **The recorder's health and the player page's state are two different
|
|
35
|
+
* facts and never share one indicator.** "The page is up" and "the recorder
|
|
36
|
+
* answered" can disagree, and a supervisor needs to know which they are
|
|
37
|
+
* looking at. Merging them is the same defect as a still tile saying "Live".
|
|
27
38
|
*
|
|
28
39
|
* These match the iSecure365 mobile monitoring build one for one, deliberately:
|
|
29
40
|
* the same product should not answer the same question differently on a phone
|
|
@@ -92,7 +103,14 @@ export function wallLayout(key: string | undefined) {
|
|
|
92
103
|
|
|
93
104
|
/**
|
|
94
105
|
* At three columns a tile is small enough that the chrome starts eating the
|
|
95
|
-
* picture, so the
|
|
106
|
+
* picture, so the type shrinks and the recorder line loses its timestamp.
|
|
107
|
+
*
|
|
108
|
+
* **It no longer removes the camera's name or its status.** It used to drop the
|
|
109
|
+
* status WORD at nine tiles and leave a bare 7 px dot — a mobile-first call
|
|
110
|
+
* that is wrong on a desktop wall, which is the layout a supervisor watches
|
|
111
|
+
* from furthest away and the one where a camera is most likely to be missed.
|
|
112
|
+
* Whether a camera is alive is the tile's whole job; it is not the thing that
|
|
113
|
+
* gets cut for room.
|
|
96
114
|
*/
|
|
97
115
|
export function isDenseLayout(key: string | undefined) {
|
|
98
116
|
return wallLayout(key).columns >= 3;
|
|
@@ -111,27 +129,87 @@ export function wallCameras(items: unknown): TWallCamera[] {
|
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
/**
|
|
114
|
-
* The
|
|
132
|
+
* The cameras a wall would show, before the layout cuts them down.
|
|
115
133
|
*
|
|
116
134
|
* An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
|
|
117
135
|
* and the wall falls back to the site's own order. A supervisor who clears the
|
|
118
136
|
* picker gets their site back, not a black screen.
|
|
119
137
|
*/
|
|
120
|
-
|
|
138
|
+
function wallChosen(
|
|
139
|
+
cameras: TWallCamera[],
|
|
140
|
+
selectedIds: string[] | undefined
|
|
141
|
+
): TWallCamera[] {
|
|
142
|
+
if (!selectedIds || !selectedIds.length) return cameras;
|
|
143
|
+
const byId = new Map(cameras.map((c) => [c._id, c]));
|
|
144
|
+
return selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How many pages the wall needs — never fewer than one.
|
|
149
|
+
*
|
|
150
|
+
* **This exists because it was missing, and a camera was going missing with
|
|
151
|
+
* it.** The wall took the first `tiles` cameras and dropped the rest on the
|
|
152
|
+
* floor: a site with five cameras on a 2x2 wall showed four, said "Cameras 5"
|
|
153
|
+
* in the toolbar, and gave nobody any way to reach the fifth. It did not read
|
|
154
|
+
* as a missing feature, it read as a broken count. The mobile wall has paged
|
|
155
|
+
* since it was built; this is the same rule, on the web.
|
|
156
|
+
*/
|
|
157
|
+
export function wallPageCount(
|
|
121
158
|
cameras: TWallCamera[],
|
|
122
159
|
selectedIds: string[] | undefined,
|
|
123
160
|
layoutKey: string | undefined
|
|
124
|
-
):
|
|
161
|
+
): number {
|
|
162
|
+
const total = wallChosen(cameras, selectedIds).length;
|
|
163
|
+
return Math.max(1, Math.ceil(total / wallLayout(layoutKey).tiles));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Keeps a page number inside the wall, whatever the layout just did to it. */
|
|
167
|
+
export function clampPage(page: number, pages: number): number {
|
|
168
|
+
if (!Number.isFinite(page)) return 0;
|
|
169
|
+
return Math.min(Math.max(0, Math.floor(page)), Math.max(0, pages - 1));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The cameras on one page, in order. **Never padded.**
|
|
174
|
+
*
|
|
175
|
+
* It used to pad to the layout's tile count with `null`, and the wall drew each
|
|
176
|
+
* one as a dashed box reading "No camera in this position." Five cameras on a
|
|
177
|
+
* nine-tile wall meant four dead grey rectangles, which is not what a video
|
|
178
|
+
* wall does and not information: nobody looking at that screen was unaware
|
|
179
|
+
* they had five cameras, and there is nothing to drop into an empty cell
|
|
180
|
+
* because cameras are added in Site Settings, not here.
|
|
181
|
+
*
|
|
182
|
+
* So the grid packs instead — see `wallRows`. The layout picker still means
|
|
183
|
+
* "at most this many, at this size"; it stops meaning "always exactly this
|
|
184
|
+
* many holes".
|
|
185
|
+
*/
|
|
186
|
+
export function wallTiles(
|
|
187
|
+
cameras: TWallCamera[],
|
|
188
|
+
selectedIds: string[] | undefined,
|
|
189
|
+
layoutKey: string | undefined,
|
|
190
|
+
page = 0
|
|
191
|
+
): TWallCamera[] {
|
|
125
192
|
const { tiles } = wallLayout(layoutKey);
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
193
|
+
const chosen = wallChosen(cameras, selectedIds);
|
|
194
|
+
const start = clampPage(page, wallPageCount(cameras, selectedIds, layoutKey)) * tiles;
|
|
195
|
+
|
|
196
|
+
return chosen.slice(start, start + tiles);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* How many grid rows this page actually needs — the other half of packing.
|
|
201
|
+
*
|
|
202
|
+
* Without it a 3x3 wall holding five cameras still reserves three rows, so the
|
|
203
|
+
* five tiles are drawn two-thirds height with a band of empty screen under
|
|
204
|
+
* them. Sizing the grid to the content gives that screen back to the pictures,
|
|
205
|
+
* which is the entire reason a supervisor chose a bigger layout.
|
|
206
|
+
*
|
|
207
|
+
* Never more rows than the layout would have had, and never fewer than one.
|
|
208
|
+
*/
|
|
209
|
+
export function wallRows(count: number, layoutKey: string | undefined): number {
|
|
210
|
+
const { columns, tiles } = wallLayout(layoutKey);
|
|
211
|
+
const capped = Math.min(Math.max(0, Math.floor(count)), tiles);
|
|
212
|
+
return Math.min(Math.ceil(tiles / columns), Math.max(1, Math.ceil(capped / columns)));
|
|
135
213
|
}
|
|
136
214
|
|
|
137
215
|
/* -------------------------------------------------------------------------- */
|
|
@@ -146,6 +224,7 @@ export function wallTiles(
|
|
|
146
224
|
* waiting on something somebody has to switch on.
|
|
147
225
|
*/
|
|
148
226
|
export const CAPABILITY_TAGS = {
|
|
227
|
+
supported: "Ready",
|
|
149
228
|
unsupported: "Not set up yet",
|
|
150
229
|
unknown: "Not checked yet",
|
|
151
230
|
} as const;
|
|
@@ -157,24 +236,121 @@ export const CAPABILITY_TAGS = {
|
|
|
157
236
|
*/
|
|
158
237
|
const NOT_PROBED = "This camera has not been asked what it can do yet.";
|
|
159
238
|
|
|
160
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* RULE 5 — the second line, and why the server cannot write it.
|
|
241
|
+
*
|
|
242
|
+
* The server's sentence is a statement of fact: *"No direct connection to this
|
|
243
|
+
* camera's recorder is configured on this server."* That is correct, it is the
|
|
244
|
+
* server's to own, and it is **not** an answer to the only question a reader
|
|
245
|
+
* actually has, which is "so how do I set it up?" — a real question, asked of
|
|
246
|
+
* this exact screen. A server cannot answer it: it knows the configuration is
|
|
247
|
+
* absent, it has no idea who the person reading has to go and ask.
|
|
248
|
+
*
|
|
249
|
+
* So the next step lives here, keyed by the server's own reason CODE rather
|
|
250
|
+
* than by matching its words. Rule 3 is untouched — the server's sentence is
|
|
251
|
+
* still shown verbatim; this is a second line under it.
|
|
252
|
+
*
|
|
253
|
+
* **Every one of these says the same true and unwelcome thing in a different
|
|
254
|
+
* way: none of it is switched on from this page.** Saying so plainly is the
|
|
255
|
+
* point. An empty settings screen that quietly does nothing would be worse.
|
|
256
|
+
*/
|
|
257
|
+
export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
|
|
258
|
+
"device-http-not-configured":
|
|
259
|
+
"This is a server setting, not something to switch on from this page: the server needs a network route to the camera's recorder, plus the recorder's sign-in details. Ask your Seven365 administrator.",
|
|
260
|
+
"device-http-disabled":
|
|
261
|
+
"Direct camera access is switched off on this server. Ask your Seven365 administrator to switch it on.",
|
|
262
|
+
"device-http-unreachable":
|
|
263
|
+
"The recorder did not answer this server. Ask your Seven365 administrator or the camera installer to check the recorder and its network.",
|
|
264
|
+
"device-http-locked-out":
|
|
265
|
+
"Sign-ins to the recorder are being held back so its account is not locked out. Ask your Seven365 administrator to check the recorder's sign-in details.",
|
|
266
|
+
"control-not-enabled":
|
|
267
|
+
"Camera control is switched off on this server. Switching it on moves real hardware, so it has to be approved by a Seven365 backend lead first.",
|
|
268
|
+
"device-no-ptz":
|
|
269
|
+
"This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
|
|
270
|
+
"no-recorder-configured":
|
|
271
|
+
"No recorder is set up for this camera's video service. Ask your Seven365 administrator to add it.",
|
|
272
|
+
"no-channel-in-address":
|
|
273
|
+
"This camera's address does not say which stream it is. Correct it in Site Settings, under CCTV.",
|
|
274
|
+
"not-a-relay-player-url":
|
|
275
|
+
"This camera's address is not a live-video page address. Correct it in Site Settings, under CCTV.",
|
|
276
|
+
"no-address":
|
|
277
|
+
"This camera has no address. Add it in Site Settings, under CCTV.",
|
|
278
|
+
"invalid-address":
|
|
279
|
+
"This camera's address is not valid. Correct it in Site Settings, under CCTV.",
|
|
280
|
+
"camera-inactive":
|
|
281
|
+
"This camera is switched off. Set it back to active in Site Settings, under CCTV.",
|
|
282
|
+
"device-not-probed":
|
|
283
|
+
"The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
|
|
284
|
+
"no-transport":
|
|
285
|
+
"Nothing on this server can do this yet. It has to be built before it can be switched on.",
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* For a reason code this build has never heard of — a newer server, a code
|
|
290
|
+
* added after this layer was published. Deliberately vague about the cause and
|
|
291
|
+
* exact about the next step, which is the half that matters: it is still not a
|
|
292
|
+
* setting on this page, and there is still somebody to ask.
|
|
293
|
+
*/
|
|
294
|
+
const NEXT_STEP_FALLBACK =
|
|
295
|
+
"This is not something to switch on from this page. Ask your Seven365 administrator.";
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The capabilities the wall draws a control for, and their plain names.
|
|
299
|
+
*
|
|
300
|
+
* `ready` is what the panel says when the server reports the capability
|
|
301
|
+
* WORKING, and it describes **this screen** rather than the product. Only
|
|
302
|
+
* `digitalZoom` is actually built here; the other four are real on the camera
|
|
303
|
+
* and not yet drawn on the web, and saying so is better than a panel that
|
|
304
|
+
* points at a timeline nobody has built. Today the server reports all four
|
|
305
|
+
* unsupported, so none of those sentences is on screen — they are there so
|
|
306
|
+
* that the day one flips, the screen does not start lying.
|
|
307
|
+
*/
|
|
161
308
|
export const WALL_CAPABILITY_CONTROLS = [
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
309
|
+
{
|
|
310
|
+
key: "digitalZoom",
|
|
311
|
+
title: "Zoom",
|
|
312
|
+
ready:
|
|
313
|
+
"Scroll or pinch on a tile to zoom in, then drag to move around. Reset returns to the whole picture. This crops and enlarges the picture on your screen — it does not move the camera.",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: "playback",
|
|
317
|
+
title: "Recorded footage",
|
|
318
|
+
ready: "This camera can serve recorded footage. The playback controls are not on this screen yet.",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
key: "events",
|
|
322
|
+
title: "Events and alarms",
|
|
323
|
+
ready: "This camera reports events. The event list is not on this screen yet.",
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
key: "ptz",
|
|
327
|
+
title: "Move camera",
|
|
328
|
+
ready: "This camera can be moved. The movement controls are not on this screen yet.",
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
key: "presets",
|
|
332
|
+
title: "Stored positions",
|
|
333
|
+
ready: "This camera has stored positions. Recalling them is not on this screen yet.",
|
|
334
|
+
},
|
|
166
335
|
] as const;
|
|
167
336
|
|
|
168
337
|
export type TCapabilityView = {
|
|
169
338
|
state: "supported" | "unsupported" | "unknown";
|
|
170
339
|
available: boolean;
|
|
171
|
-
/**
|
|
172
|
-
tag: string
|
|
173
|
-
/**
|
|
174
|
-
|
|
340
|
+
/** Always set: "Ready" when it works, the reason wording when it does not. */
|
|
341
|
+
tag: string;
|
|
342
|
+
/**
|
|
343
|
+
* The server's sentence, or the not-probed one — and `null` when the
|
|
344
|
+
* capability WORKS, because the server sends no sentence for a working
|
|
345
|
+
* capability and falling back to the not-probed one made a working control
|
|
346
|
+
* claim it had never been checked.
|
|
347
|
+
*/
|
|
348
|
+
detail: string | null;
|
|
349
|
+
/** Rule 5: what would switch it on and who to ask. `null` when it works. */
|
|
350
|
+
nextStep: string | null;
|
|
175
351
|
};
|
|
176
352
|
|
|
177
|
-
/**
|
|
353
|
+
/** Rules 2, 3 and 5, in one place. */
|
|
178
354
|
export function capabilityView(
|
|
179
355
|
camera: TWallCamera | null | undefined,
|
|
180
356
|
key: string
|
|
@@ -185,11 +361,28 @@ export function capabilityView(
|
|
|
185
361
|
? entry.state
|
|
186
362
|
: "unknown";
|
|
187
363
|
|
|
364
|
+
if (state === "supported") {
|
|
365
|
+
return {
|
|
366
|
+
state,
|
|
367
|
+
available: true,
|
|
368
|
+
tag: CAPABILITY_TAGS.supported,
|
|
369
|
+
detail: null,
|
|
370
|
+
nextStep: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const reason = (entry?.reason || "").trim();
|
|
375
|
+
|
|
188
376
|
return {
|
|
189
377
|
state,
|
|
190
|
-
available:
|
|
191
|
-
tag:
|
|
378
|
+
available: false,
|
|
379
|
+
tag: CAPABILITY_TAGS[state],
|
|
192
380
|
detail: (entry?.detail || "").trim() || NOT_PROBED,
|
|
381
|
+
nextStep:
|
|
382
|
+
CAPABILITY_NEXT_STEPS[reason] ??
|
|
383
|
+
(state === "unknown"
|
|
384
|
+
? CAPABILITY_NEXT_STEPS["device-not-probed"]
|
|
385
|
+
: NEXT_STEP_FALLBACK),
|
|
193
386
|
};
|
|
194
387
|
}
|
|
195
388
|
|
|
@@ -205,47 +398,193 @@ export function capabilityView(
|
|
|
205
398
|
*/
|
|
206
399
|
export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
|
|
207
400
|
|
|
401
|
+
/**
|
|
402
|
+
* How long a tile that WAS confirmed playing may go without another frame
|
|
403
|
+
* before it stops saying "Live".
|
|
404
|
+
*
|
|
405
|
+
* Only reachable once the video service reports frames at all (see
|
|
406
|
+
* `playerSignal`). It exists because the opposite mistake — a tile that went
|
|
407
|
+
* green once and stays green over a frozen picture — is the same defect as the
|
|
408
|
+
* mobile app's "Live" badge over a nine-second-old still, and is exactly what
|
|
409
|
+
* this whole rewrite is fixing.
|
|
410
|
+
*/
|
|
411
|
+
export const LIVE_FRAME_TIMEOUT_MS = 12_000;
|
|
412
|
+
|
|
208
413
|
export type TTileState =
|
|
209
414
|
| "live"
|
|
415
|
+
| "unverified"
|
|
210
416
|
| "connecting"
|
|
211
417
|
| "no-signal"
|
|
212
418
|
| "offline"
|
|
213
419
|
| "unavailable";
|
|
214
420
|
|
|
215
421
|
/**
|
|
216
|
-
* The
|
|
422
|
+
* The words a tile may say about itself.
|
|
217
423
|
*
|
|
218
424
|
* `offline` and `no-signal` are two different facts and used to share one word:
|
|
219
425
|
* "Offline" on every tile at once means the BROWSER lost the network; "No
|
|
220
426
|
* signal" on one tile means that camera is not answering. A supervisor needs to
|
|
221
427
|
* know which of those they are looking at.
|
|
428
|
+
*
|
|
429
|
+
* `unverified` is the state this wall is in for every camera on this estate
|
|
430
|
+
* today, and saying so is the point — see the note above `tileView`.
|
|
222
431
|
*/
|
|
223
432
|
export const TILE_LABELS = {
|
|
224
433
|
live: "Live",
|
|
434
|
+
unverified: "Not verified",
|
|
225
435
|
connecting: "Connecting…",
|
|
226
436
|
"no-signal": "No signal",
|
|
227
437
|
offline: "No connection",
|
|
228
438
|
unavailable: "Not set up yet",
|
|
229
439
|
} as const;
|
|
230
440
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
441
|
+
/**
|
|
442
|
+
* What the browser knows about the embedded player page.
|
|
443
|
+
*
|
|
444
|
+
* - `loading` — nothing yet; the document request has not finished.
|
|
445
|
+
* - `page-up` — the document loaded. **This says nothing about pictures.**
|
|
446
|
+
* - `playing` — the video service reported a decoded frame (see `playerSignal`).
|
|
447
|
+
* - `stalled` — it reported frames and then stopped, or said it stalled.
|
|
448
|
+
* - `failed` — the document never loaded, or the browser reported an error.
|
|
449
|
+
*/
|
|
450
|
+
export type TPlayerState = "loading" | "page-up" | "playing" | "stalled" | "failed";
|
|
451
|
+
|
|
452
|
+
/* ------------------------------------------------------------------------- */
|
|
453
|
+
/* What the video service can tell us, and what it cannot */
|
|
454
|
+
/* ------------------------------------------------------------------------- */
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* EVERYTHING THIS BROWSER CAN HONESTLY KNOW ABOUT A CROSS-ORIGIN PLAYER.
|
|
458
|
+
*
|
|
459
|
+
* The player is another origin's page in an `<iframe>`, so the list is short
|
|
460
|
+
* and it is worth writing down, because the badge is built only from this:
|
|
461
|
+
*
|
|
462
|
+
* 1. **`load` fires** → that origin returned a document. Nothing more. The
|
|
463
|
+
* video service answers `GET /<channel>` with the same HTML for a channel
|
|
464
|
+
* that has no camera behind it, so `load` on a dead camera is normal.
|
|
465
|
+
* 2. **`load` never fires** → DNS, TLS or the connection failed, or the service
|
|
466
|
+
* is down. Only observable as *absence*, hence the timeout.
|
|
467
|
+
* 3. **`error` fires** → rare in practice for cross-origin frames, but free.
|
|
468
|
+
* 4. **`postMessage` from that origin** → the only channel that could carry
|
|
469
|
+
* "a frame decoded". The service does not send one today; `playerSignal`
|
|
470
|
+
* below is the listener, so the day it does, the wall goes green with no
|
|
471
|
+
* further frontend release.
|
|
472
|
+
*
|
|
473
|
+
* NOT available, and we must stop pretending otherwise: the frame rate, the
|
|
474
|
+
* canvas pixels, the WebSocket's state, whether ffmpeg upstream is producing
|
|
475
|
+
* anything. All of those live inside the other origin's document and the
|
|
476
|
+
* browser blocks every route to them — correctly.
|
|
477
|
+
*
|
|
478
|
+
* Opening our own WebSocket to the service to check would start a SECOND
|
|
479
|
+
* decode of the same camera per tile, which is a real load on the recorder for
|
|
480
|
+
* a status dot. Rejected.
|
|
481
|
+
*/
|
|
482
|
+
export type TPlayerSignal = "playing" | "stalled" | "failed" | null;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Reads a `postMessage` payload from the video service.
|
|
486
|
+
*
|
|
487
|
+
* Deliberately forgiving about shape and deliberately strict about origin —
|
|
488
|
+
* the origin check belongs to the caller, which has the camera's address; this
|
|
489
|
+
* only decides what a message MEANS. Anything unrecognised is `null`, which
|
|
490
|
+
* leaves the tile exactly where it was: an unknown message must never be able
|
|
491
|
+
* to turn a badge green.
|
|
492
|
+
*/
|
|
493
|
+
export function playerSignal(data: unknown): TPlayerSignal {
|
|
494
|
+
const event =
|
|
495
|
+
typeof data === "string"
|
|
496
|
+
? data
|
|
497
|
+
: data && typeof data === "object"
|
|
498
|
+
? String((data as Record<string, unknown>).event ?? (data as Record<string, unknown>).type ?? "")
|
|
499
|
+
: "";
|
|
500
|
+
|
|
501
|
+
switch (event.trim().toLowerCase()) {
|
|
502
|
+
case "frame":
|
|
503
|
+
case "videodecode":
|
|
504
|
+
case "playing":
|
|
505
|
+
return "playing";
|
|
506
|
+
case "stalled":
|
|
507
|
+
case "paused":
|
|
508
|
+
return "stalled";
|
|
509
|
+
case "error":
|
|
510
|
+
case "sourceclosed":
|
|
511
|
+
return "failed";
|
|
512
|
+
default:
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The origin a message must come from to be believed, or `""` for an address
|
|
519
|
+
* that is not a URL at all. Kept here so the rule is tested rather than
|
|
520
|
+
* inlined in a component.
|
|
521
|
+
*/
|
|
522
|
+
export function playerOrigin(host: string | null | undefined): string {
|
|
523
|
+
if (!host) return "";
|
|
524
|
+
try {
|
|
525
|
+
return new URL(host).origin;
|
|
526
|
+
} catch {
|
|
527
|
+
return "";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
233
530
|
|
|
234
531
|
export type TTileView = {
|
|
235
532
|
state: TTileState;
|
|
236
533
|
label: string;
|
|
237
|
-
/**
|
|
534
|
+
/**
|
|
535
|
+
* A sentence that COVERS THE TILE. Only for states where there is nothing
|
|
536
|
+
* worth looking at underneath it — never over a picture that may be fine.
|
|
537
|
+
*/
|
|
238
538
|
detail: string | null;
|
|
539
|
+
/**
|
|
540
|
+
* A quieter sentence for the badge's tooltip and the focused camera's panel.
|
|
541
|
+
* `unverified` uses this rather than `detail` for exactly one reason: there
|
|
542
|
+
* may well be a good picture under that badge, and a scrim over a working
|
|
543
|
+
* camera would be a worse lie than the one being fixed.
|
|
544
|
+
*/
|
|
545
|
+
note: string | null;
|
|
239
546
|
/** Whether the player page may be embedded at all. */
|
|
240
547
|
showPlayer: boolean;
|
|
241
548
|
};
|
|
242
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The sentence behind "Not verified", said once on the focused camera's panel
|
|
552
|
+
* rather than nine times across the wall. Invented here: no server has a
|
|
553
|
+
* reason code for "your browser cannot see inside another origin's page".
|
|
554
|
+
*/
|
|
555
|
+
export const UNVERIFIED_DETAIL =
|
|
556
|
+
"The video page loaded, but this browser cannot confirm a picture is arriving. The player belongs to the video service and a browser cannot look inside it. Judge the tile by what you can see, and report a blank or frozen picture.";
|
|
557
|
+
|
|
243
558
|
/**
|
|
244
559
|
* Rule 4, plus every reason a tile may have nothing to show.
|
|
245
560
|
*
|
|
246
561
|
* Order matters: the browser being offline beats everything (it explains every
|
|
247
562
|
* tile at once), then the record's own problems, then the server's verdict on
|
|
248
563
|
* live video, and only then what the embedded page is actually doing.
|
|
564
|
+
*
|
|
565
|
+
* ## WHY THIS NO LONGER SAYS "Live" WHEN THE PAGE LOADS
|
|
566
|
+
*
|
|
567
|
+
* It used to, and it was wrong in production: on a live wall, one camera
|
|
568
|
+
* rendered pure white and another pure black, and both carried a green "Live".
|
|
569
|
+
* A third, on a nine-tile layout, was a black rectangle with a green dot. In
|
|
570
|
+
* every case the player page had loaded and no picture was arriving.
|
|
571
|
+
*
|
|
572
|
+
* The badge was asserting a fact nobody had checked. The video service returns
|
|
573
|
+
* its player HTML for any channel — including one with no camera behind it —
|
|
574
|
+
* so the `load` event proves the SERVICE is up and proves nothing at all about
|
|
575
|
+
* the CAMERA. Building "Live" on it is the same class of defect as the mobile
|
|
576
|
+
* app's "Live" badge over a nine-second-old still: a guard is told he is
|
|
577
|
+
* watching something he is not.
|
|
578
|
+
*
|
|
579
|
+
* So the page loading now earns `unverified`, and `live` is reserved for a
|
|
580
|
+
* frame the video service actually reported (`playerSignal`). Today no camera
|
|
581
|
+
* on this estate will reach `live`, and that is the honest answer rather than
|
|
582
|
+
* a comfortable one. The two-line change on the video service's side that
|
|
583
|
+
* would light these up is described in the PR; it is not ours to make.
|
|
584
|
+
*
|
|
585
|
+
* A tile deliberately does NOT decay from `unverified` to `no-signal` on a
|
|
586
|
+
* timer. Asserting death without evidence is the same mistake as asserting
|
|
587
|
+
* life without evidence, pointed the other way.
|
|
249
588
|
*/
|
|
250
589
|
export function tileView(
|
|
251
590
|
camera: TWallCamera | null | undefined,
|
|
@@ -256,6 +595,7 @@ export function tileView(
|
|
|
256
595
|
state,
|
|
257
596
|
label: TILE_LABELS[state],
|
|
258
597
|
detail,
|
|
598
|
+
note: detail,
|
|
259
599
|
showPlayer: false,
|
|
260
600
|
});
|
|
261
601
|
|
|
@@ -277,23 +617,190 @@ export function tileView(
|
|
|
277
617
|
if (live.state === "unsupported") return dead("unavailable", live.detail);
|
|
278
618
|
|
|
279
619
|
const player = opts.player ?? "loading";
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
620
|
+
const showing = (state: TTileState, detail: string | null): TTileView => ({
|
|
621
|
+
state,
|
|
622
|
+
label: TILE_LABELS[state],
|
|
623
|
+
detail,
|
|
624
|
+
note: detail,
|
|
625
|
+
showPlayer: true,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
switch (player) {
|
|
629
|
+
case "failed":
|
|
284
630
|
// Invented: only the browser can know the page never loaded, so the
|
|
285
631
|
// server has no reason code for it. Worded as a fact, not a diagnosis —
|
|
286
632
|
// we genuinely cannot tell a dead camera from a dead video service.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
633
|
+
return showing(
|
|
634
|
+
"no-signal",
|
|
635
|
+
"The live view did not load. The camera or the video service may be down."
|
|
636
|
+
);
|
|
637
|
+
case "stalled":
|
|
638
|
+
// It WAS playing and stopped. That is the one case where we can say a
|
|
639
|
+
// picture has genuinely gone away, because we saw one arrive first.
|
|
640
|
+
return showing(
|
|
641
|
+
"no-signal",
|
|
642
|
+
"The picture stopped arriving. The camera or the video service may have dropped."
|
|
643
|
+
);
|
|
644
|
+
case "playing":
|
|
645
|
+
return showing("live", null);
|
|
646
|
+
case "page-up":
|
|
647
|
+
return { ...showing("unverified", null), note: UNVERIFIED_DETAIL };
|
|
648
|
+
default:
|
|
649
|
+
return showing("connecting", null);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* -------------------------------------------------------------------------- */
|
|
654
|
+
/* Health — RULE 6 */
|
|
655
|
+
/* -------------------------------------------------------------------------- */
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* One camera's health, as `GET /site-cameras/site/:siteId/health` returns it.
|
|
659
|
+
*
|
|
660
|
+
* **Reachability is a per-RECORDER answer**, cached per recorder on the server:
|
|
661
|
+
* twelve cameras hanging off one device is one TCP connect shared between the
|
|
662
|
+
* tiles, not twelve. That is why a wall may ask for this at all.
|
|
663
|
+
*
|
|
664
|
+
* **`firmwareVersion` and `deviceTime` are null on this estate and say why.**
|
|
665
|
+
* They are facts you get over the recorder's HTTP interface, which is not
|
|
666
|
+
* reachable — so the server sends `detailUnavailableReason` rather than leaving
|
|
667
|
+
* two empty fields to read as "checked, and fine".
|
|
668
|
+
*/
|
|
669
|
+
export type TWallCameraHealth = {
|
|
670
|
+
health?: "ok" | "drifted" | "unreachable" | "unsupported" | null;
|
|
671
|
+
reachable?: boolean | null;
|
|
672
|
+
reason?: string | null;
|
|
673
|
+
/** ISO timestamp of the last picture this server actually got. */
|
|
674
|
+
lastFrameAt?: string | null;
|
|
675
|
+
driftSeconds?: number | null;
|
|
676
|
+
firmwareVersion?: string | null;
|
|
677
|
+
deviceTime?: string | null;
|
|
678
|
+
detailUnavailableReason?: string | null;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
export type THealthTone = "ok" | "warn" | "down" | "unknown";
|
|
682
|
+
|
|
683
|
+
export type THealthView = {
|
|
684
|
+
tone: THealthTone;
|
|
685
|
+
/** Always a whole short phrase — never a bare word beside a dot. */
|
|
686
|
+
label: string;
|
|
687
|
+
/** The server's own sentence when it sent one. */
|
|
688
|
+
detail: string | null;
|
|
689
|
+
/** "4 min ago", or `null` when this server has never had a picture. */
|
|
690
|
+
lastFrame: string | null;
|
|
691
|
+
/** Why firmware and clock are blank. The server's sentence, verbatim. */
|
|
692
|
+
deviceDetail: string | null;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
|
|
696
|
+
ok: { tone: "ok", label: "Recorder responding" },
|
|
697
|
+
drifted: { tone: "warn", label: "Recorder clock is out" },
|
|
698
|
+
unreachable: { tone: "down", label: "Recorder not responding" },
|
|
699
|
+
unsupported: { tone: "unknown", label: "Not checked" },
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Rule 6. This is about the RECORDER, and the tile's own badge is about the
|
|
704
|
+
* player page — the two are reported separately and never merged, because they
|
|
705
|
+
* genuinely disagree: a working relay in front of a dead recorder shows a live
|
|
706
|
+
* page and an unreachable device, and a supervisor who is told only "Live"
|
|
707
|
+
* learns the wrong thing.
|
|
708
|
+
*/
|
|
709
|
+
export function healthView(
|
|
710
|
+
health: TWallCameraHealth | null | undefined,
|
|
711
|
+
now: number = Date.now()
|
|
712
|
+
): THealthView {
|
|
713
|
+
const known = health?.health ? HEALTH_LABELS[health.health] : undefined;
|
|
714
|
+
const fallback: { tone: THealthTone; label: string } =
|
|
715
|
+
health?.reachable === true
|
|
716
|
+
? HEALTH_LABELS.ok
|
|
717
|
+
: health?.reachable === false
|
|
718
|
+
? HEALTH_LABELS.unreachable
|
|
719
|
+
: { tone: "unknown", label: "Health not checked yet" };
|
|
720
|
+
|
|
721
|
+
const { tone, label } = known ?? fallback;
|
|
297
722
|
|
|
298
|
-
return {
|
|
723
|
+
return {
|
|
724
|
+
tone,
|
|
725
|
+
label,
|
|
726
|
+
detail: (health?.reason || "").trim() || null,
|
|
727
|
+
lastFrame: agoLabel(health?.lastFrameAt, now),
|
|
728
|
+
deviceDetail: (health?.detailUnavailableReason || "").trim() || null,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* "just now" / "40s ago" / "6 min ago" / "3 h ago".
|
|
734
|
+
*
|
|
735
|
+
* Coarse on purpose above a minute: a supervisor is deciding whether a picture
|
|
736
|
+
* is CURRENT, and "6 min ago" answers that where "371s ago" makes them do
|
|
737
|
+
* arithmetic to reach the same answer.
|
|
738
|
+
*/
|
|
739
|
+
export function agoLabel(iso: string | null | undefined, now: number = Date.now()): string | null {
|
|
740
|
+
if (!iso) return null;
|
|
741
|
+
const at = Date.parse(iso);
|
|
742
|
+
if (!Number.isFinite(at)) return null;
|
|
743
|
+
|
|
744
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
745
|
+
if (seconds < 5) return "just now";
|
|
746
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
747
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
|
|
748
|
+
return `${Math.round(seconds / 3600)} h ago`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/* -------------------------------------------------------------------------- */
|
|
752
|
+
/* Digital zoom */
|
|
753
|
+
/* -------------------------------------------------------------------------- */
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* DIGITAL ZOOM IS CROPPING, NOT PTZ, AND THE DIFFERENCE IS THE WHOLE POINT.
|
|
757
|
+
*
|
|
758
|
+
* This magnifies the picture already on the screen. **No request leaves the
|
|
759
|
+
* browser and no hardware moves** — which is exactly why it works today on
|
|
760
|
+
* every camera while "Move camera" does not, and why the two must never look
|
|
761
|
+
* like the same control.
|
|
762
|
+
*
|
|
763
|
+
* The arithmetic is identical to the mobile build's (`zoomable.tsx`), including
|
|
764
|
+
* the 4x ceiling, so the same gesture gives the same result on a phone and on a
|
|
765
|
+
* laptop.
|
|
766
|
+
*/
|
|
767
|
+
export const MAX_DIGITAL_ZOOM = 4;
|
|
768
|
+
|
|
769
|
+
/** Beyond 4x a substream frame is enlarged pixels — a magnification that shows
|
|
770
|
+
* less than you started with is a control that lies. */
|
|
771
|
+
export function clampZoom(scale: number): number {
|
|
772
|
+
if (!Number.isFinite(scale)) return 1;
|
|
773
|
+
return Math.min(MAX_DIGITAL_ZOOM, Math.max(1, scale));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Keeps the magnified picture covering the tile.
|
|
778
|
+
*
|
|
779
|
+
* At scale `s` the picture is `s` times the frame, so it may be moved by half
|
|
780
|
+
* the overflow each way and no further. Without this a drag walks the picture
|
|
781
|
+
* off the tile and leaves a supervisor looking at the background — arriving at
|
|
782
|
+
* the blank-rectangle failure this whole surface exists to prevent, by gesture.
|
|
783
|
+
*/
|
|
784
|
+
export function clampPan(
|
|
785
|
+
offset: { x: number; y: number },
|
|
786
|
+
scale: number,
|
|
787
|
+
frame: { width: number; height: number }
|
|
788
|
+
): { x: number; y: number } {
|
|
789
|
+
const limitX = Math.max(0, (frame.width * (scale - 1)) / 2);
|
|
790
|
+
const limitY = Math.max(0, (frame.height * (scale - 1)) / 2);
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
x: Math.min(limitX, Math.max(-limitX, offset.x || 0)),
|
|
794
|
+
y: Math.min(limitY, Math.max(-limitY, offset.y || 0)),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* One wheel notch. `deltaY` varies wildly between a mouse, a trackpad and a
|
|
800
|
+
* browser's delta mode, so the DIRECTION is used and the magnitude is not: a
|
|
801
|
+
* fixed step is predictable everywhere, and predictability is worth more here
|
|
802
|
+
* than proportionality.
|
|
803
|
+
*/
|
|
804
|
+
export function zoomStep(scale: number, deltaY: number): number {
|
|
805
|
+
return clampZoom(deltaY < 0 ? scale * 1.25 : scale / 1.25);
|
|
299
806
|
}
|