@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.
@@ -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". The tile
25
- * says `Connecting…` until the player page is actually up, and `No signal`
26
- * when it never comes.
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 tile drops its status WORD and keeps the dot.
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 tiles, in order, padded with `null` to the layout's size.
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
- export function wallTiles(
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
- ): Array<TWallCamera | null> {
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 byId = new Map(cameras.map((c) => [c._id, c]));
127
- const chosen =
128
- selectedIds && selectedIds.length
129
- ? selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c)
130
- : cameras;
131
-
132
- const out: Array<TWallCamera | null> = chosen.slice(0, tiles);
133
- while (out.length < tiles) out.push(null);
134
- return out;
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
- /** The four capabilities the wall draws a control for, and their plain names. */
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
- { key: "playback", title: "Recorded footage" },
163
- { key: "events", title: "Events and alarms" },
164
- { key: "ptz", title: "Move camera" },
165
- { key: "presets", title: "Stored positions" },
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
- /** `null` when the capability works a working control needs no tag. */
172
- tag: string | null;
173
- /** The server's sentence, or the not-probed one. Never empty. */
174
- detail: string;
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
- /** Rule 2 and rule 3, in one place. */
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: state === "supported",
191
- tag: state === "supported" ? null : CAPABILITY_TAGS[state],
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 four words a tile may say about itself.
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
- /** What the browser knows about the embedded player page. */
232
- export type TPlayerState = "loading" | "ready" | "timeout";
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
- /** A sentence for the states that need explaining; `null` when live. */
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
- if (player === "timeout")
281
- return {
282
- state: "no-signal",
283
- label: TILE_LABELS["no-signal"],
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
- detail: "The live view did not load. The camera or the video service may be down.",
288
- showPlayer: true,
289
- };
290
- if (player === "loading")
291
- return {
292
- state: "connecting",
293
- label: TILE_LABELS.connecting,
294
- detail: null,
295
- showPlayer: true,
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 { state: "live", label: TILE_LABELS.live, detail: null, showPlayer: true };
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
  }