"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 } interface WakeLockLike { request(type: "screen"): Promise } 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("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(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(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 }; }