- liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon, base, tourMs, kiosk and an f.<name> filter pass-through for #235; parse/serialise stay pure and round-trip; old inc/cam/tour links parse. - MapView: overlays/basemap/viewport come from initialLayers/initialBase/ initialView (defaults unchanged); onViewStateChange reports layer, base and region-view changes (debounced). Initially-checked overlays now start "shown" (react-leaflet adds a checked overlay before the control can fire overlayadd). Closing an incident flies back to the pre-drill view (#137). - wallPrefs.ts: per-browser last layers/base/view in localStorage, every access wrapped; precedence hash > remembered > defaults. - LiveView: one effect mirrors wall + map state into the hash; hash edits remount the map with the new view; Copy view link; configurable tour. - Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first touch), Screen Wake Lock re-acquired on visibility, reconnect banner (offline / useCalls error) with backoff reload, 18-20 px type via a data-kiosk attribute and Tailwind arbitrary variants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
9.5 KiB
TypeScript
238 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Kiosk / wall-display support for the live wall (server-26#234).
|
|
*
|
|
* Kiosk is URL-driven only — `?kiosk=1` or `#…&kiosk` — never remembered in
|
|
* storage, so a normal viewer can't get stuck without the nav. A wall PC's
|
|
* startup URL carries it, which is what makes it survive a browser restart.
|
|
*
|
|
* Everything here degrades silently: no Fullscreen API, no Screen Wake Lock,
|
|
* blocked sessionStorage — the wall still renders and still updates.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
/** Fired on window whenever kiosk is switched in-page (replaceState fires no hashchange). */
|
|
export const KIOSK_EVENT = "drb:kiosk-change";
|
|
|
|
function queryKiosk(search: string): boolean {
|
|
const v = new URLSearchParams(search).get("kiosk");
|
|
return v != null && v !== "0" && v !== "false";
|
|
}
|
|
|
|
function hashKiosk(hash: string): boolean {
|
|
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
|
return p.has("kiosk") && p.get("kiosk") !== "0";
|
|
}
|
|
|
|
/** True when the current URL asks for kiosk mode (query or hash). Client-side only. */
|
|
export function readKioskFlag(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return queryKiosk(window.location.search) || hashKiosk(window.location.hash);
|
|
}
|
|
|
|
/** Whether `?kiosk=` is in the query (the hash part is LiveView's to manage). */
|
|
export function kioskInQuery(): boolean {
|
|
return typeof window !== "undefined" && queryKiosk(window.location.search);
|
|
}
|
|
|
|
/** Drops `?kiosk` from the query string in place; the caller clears the hash flag. */
|
|
export function stripKioskQuery(): void {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("kiosk");
|
|
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
|
|
} catch { /* nothing to strip */ }
|
|
}
|
|
|
|
export function announceKioskChange(): void {
|
|
window.dispatchEvent(new Event(KIOSK_EVENT));
|
|
}
|
|
|
|
/** Live view of the URL's kiosk flag, for chrome outside LiveView (ChromeSwitcher). */
|
|
export function useKioskFlag(): boolean {
|
|
const [on, setOn] = useState(false);
|
|
useEffect(() => {
|
|
const read = () => setOn(readKioskFlag());
|
|
read();
|
|
window.addEventListener("hashchange", read);
|
|
window.addEventListener("popstate", read);
|
|
window.addEventListener(KIOSK_EVENT, read);
|
|
return () => {
|
|
window.removeEventListener("hashchange", read);
|
|
window.removeEventListener("popstate", read);
|
|
window.removeEventListener(KIOSK_EVENT, read);
|
|
};
|
|
}, []);
|
|
return on;
|
|
}
|
|
|
|
// ── Screen Wake Lock (typed locally; not in every TS lib.dom) ────────────────
|
|
interface WakeLockSentinelLike extends EventTarget { released: boolean; release(): Promise<void> }
|
|
interface WakeLockLike { request(type: "screen"): Promise<WakeLockSentinelLike> }
|
|
|
|
export type WakeState = "on" | "off" | "unsupported";
|
|
|
|
const RETRY_KEY = "drb.wall.retryAttempts";
|
|
const RETRY_BASE_MS = 30_000;
|
|
const RETRY_MAX_MS = 10 * 60_000;
|
|
/** Healthy this long after a load → the retry backoff resets. */
|
|
const HEALTHY_RESET_MS = 2 * 60_000;
|
|
/** Offline at least this long → a kiosk reloads on reconnect for fresh listeners. */
|
|
const OFFLINE_RELOAD_MS = 60_000;
|
|
|
|
function readAttempts(): number {
|
|
try { return Math.max(0, Number(window.sessionStorage.getItem(RETRY_KEY)) || 0); } catch { return 0; }
|
|
}
|
|
function writeAttempts(n: number): void {
|
|
try {
|
|
if (n > 0) window.sessionStorage.setItem(RETRY_KEY, String(n));
|
|
else window.sessionStorage.removeItem(RETRY_KEY);
|
|
} catch { /* storage blocked — backoff restarts at the base delay */ }
|
|
}
|
|
|
|
export interface KioskBanner { tone: "offline" | "error"; message: string; retryAt: number | null }
|
|
|
|
export interface KioskState {
|
|
fullscreen: boolean;
|
|
canFullscreen: boolean;
|
|
toggleFullscreen: () => void;
|
|
wake: WakeState;
|
|
online: boolean;
|
|
banner: KioskBanner | null;
|
|
retryNow: () => void;
|
|
}
|
|
|
|
/**
|
|
* @param kiosk kiosk presentation is on (wake lock, auto-fullscreen, auto-reload)
|
|
* @param dataError the live-data listener's error (e.g. useCalls().error) — a
|
|
* Firestore snapshot error is terminal for that listener, so
|
|
* the recovery is a reload; the hooks themselves are untouched.
|
|
*/
|
|
export function useKiosk(kiosk: boolean, dataError: string | null): KioskState {
|
|
// ── Fullscreen ──────────────────────────────────────────────────────────
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [canFullscreen, setCanFullscreen] = useState(false);
|
|
useEffect(() => {
|
|
setCanFullscreen(typeof document !== "undefined" && !!document.fullscreenEnabled);
|
|
const on = () => setFullscreen(!!document.fullscreenElement);
|
|
on();
|
|
document.addEventListener("fullscreenchange", on);
|
|
return () => document.removeEventListener("fullscreenchange", on);
|
|
}, []);
|
|
const toggleFullscreen = useCallback(() => {
|
|
try {
|
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
|
|
else void document.documentElement.requestFullscreen().catch(() => undefined);
|
|
} catch { /* unsupported */ }
|
|
}, []);
|
|
// Browsers only allow fullscreen from a user gesture, so in kiosk the first
|
|
// touch/key anywhere enters it. A wall PC launched with the browser's own
|
|
// kiosk flag is already fullscreen and needs no click at all.
|
|
useEffect(() => {
|
|
if (!kiosk || !document.fullscreenEnabled) return;
|
|
const arm = () => {
|
|
if (!document.fullscreenElement) void document.documentElement.requestFullscreen().catch(() => undefined);
|
|
};
|
|
window.addEventListener("pointerdown", arm, { once: true });
|
|
window.addEventListener("keydown", arm, { once: true });
|
|
return () => {
|
|
window.removeEventListener("pointerdown", arm);
|
|
window.removeEventListener("keydown", arm);
|
|
};
|
|
}, [kiosk]);
|
|
|
|
// ── Screen Wake Lock — held while kiosk is on, re-acquired when visible again ──
|
|
const [wake, setWake] = useState<WakeState>("off");
|
|
useEffect(() => {
|
|
const wl = (navigator as Navigator & { wakeLock?: WakeLockLike }).wakeLock;
|
|
if (!kiosk) { setWake("off"); return; }
|
|
if (!wl) { setWake("unsupported"); return; }
|
|
let sentinel: WakeLockSentinelLike | null = null;
|
|
let cancelled = false;
|
|
const onRelease = () => { if (!cancelled) setWake("off"); };
|
|
const acquire = async () => {
|
|
if (cancelled || document.visibilityState !== "visible" || (sentinel && !sentinel.released)) return;
|
|
try {
|
|
const s = await wl.request("screen");
|
|
if (cancelled) { void s.release().catch(() => undefined); return; }
|
|
sentinel = s;
|
|
s.addEventListener("release", onRelease);
|
|
setWake("on");
|
|
} catch {
|
|
setWake("off"); // denied (battery saver, policy) — the wall still works
|
|
}
|
|
};
|
|
void acquire();
|
|
const onVis = () => { void acquire(); };
|
|
document.addEventListener("visibilitychange", onVis);
|
|
return () => {
|
|
cancelled = true;
|
|
document.removeEventListener("visibilitychange", onVis);
|
|
if (sentinel) {
|
|
sentinel.removeEventListener("release", onRelease);
|
|
void sentinel.release().catch(() => undefined);
|
|
}
|
|
};
|
|
}, [kiosk]);
|
|
|
|
// ── Reconnect watchdog ─────────────────────────────────────────────────
|
|
const [online, setOnline] = useState(true);
|
|
const offlineSince = useRef<number | null>(null);
|
|
const kioskRef = useRef(kiosk);
|
|
kioskRef.current = kiosk;
|
|
useEffect(() => {
|
|
const sync = () => {
|
|
const now = navigator.onLine;
|
|
setOnline(now);
|
|
if (!now) {
|
|
if (offlineSince.current == null) offlineSince.current = Date.now();
|
|
return;
|
|
}
|
|
const since = offlineSince.current;
|
|
offlineSince.current = null;
|
|
// Firestore resumes its listeners on its own after a blip; after a long
|
|
// outage an unattended wall reloads rather than trust that it did.
|
|
if (kioskRef.current && since != null && Date.now() - since >= OFFLINE_RELOAD_MS) window.location.reload();
|
|
};
|
|
sync();
|
|
window.addEventListener("online", sync);
|
|
window.addEventListener("offline", sync);
|
|
return () => {
|
|
window.removeEventListener("online", sync);
|
|
window.removeEventListener("offline", sync);
|
|
};
|
|
}, []);
|
|
|
|
const retryNow = useCallback(() => {
|
|
writeAttempts(readAttempts() + 1);
|
|
window.location.reload();
|
|
}, []);
|
|
|
|
// Healthy for a while → forget earlier failures.
|
|
useEffect(() => {
|
|
if (dataError) return;
|
|
const id = setTimeout(() => writeAttempts(0), HEALTHY_RESET_MS);
|
|
return () => clearTimeout(id);
|
|
}, [dataError]);
|
|
|
|
// Data error in kiosk → reload after an exponential backoff (30 s → 10 min).
|
|
const [retryAt, setRetryAt] = useState<number | null>(null);
|
|
useEffect(() => {
|
|
if (!dataError || !kiosk || !online) { setRetryAt(null); return; }
|
|
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** readAttempts());
|
|
setRetryAt(Date.now() + delay);
|
|
const id = setTimeout(retryNow, delay);
|
|
return () => clearTimeout(id);
|
|
}, [dataError, kiosk, online, retryNow]);
|
|
|
|
let banner: KioskBanner | null = null;
|
|
if (!online) {
|
|
banner = { tone: "offline", message: "Offline — showing the last data received. Reconnects automatically.", retryAt: null };
|
|
} else if (dataError) {
|
|
banner = { tone: "error", message: "Live data stopped updating.", retryAt };
|
|
}
|
|
|
|
return { fullscreen, canFullscreen, toggleFullscreen, wake, online, banner, retryNow };
|
|
}
|