"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { Kind, MarkerExtra, MarkerScreen } from "@/lib/markers"; type Mode = "full" | "edge"; type Showing = { src: string; label: string; detail: string; screen?: MarkerScreen; extra?: MarkerExtra }; const KIND_LABEL: Record = { phone: "Phones", tablet: "Tablets", laptop: "Laptops", monitor: "Monitors" }; const MODE_KEY = "ttvfx-set-mode"; // Must match CACHE in public/set/sw.js const OFFLINE_CACHE = "ttvfx-set-v1"; function detectScreen() { const dpr = window.devicePixelRatio || 1; let w = Math.round(window.screen.width * dpr); let h = Math.round(window.screen.height * dpr); const landscape = window.matchMedia("(orientation: landscape)").matches; // iOS reports screen size in portrait regardless of rotation if (landscape !== w > h) [w, h] = [h, w]; return { w, h, landscape }; } function bestMatch(screens: MarkerScreen[], w: number, h: number) { const pool = screens.filter((s) => s.width > s.height === w > h); const candidates = pool.length ? pool : screens; let best: { s: MarkerScreen; score: number } | null = null; for (const s of candidates) { const aspect = Math.abs(s.width / s.height - w / h); const size = Math.abs(s.width * s.height - w * h) / (s.width * s.height); const score = aspect * 10 + size; if (!best || score < best.score) best = { s, score }; } if (!best) return null; const exact = Math.abs(best.s.width - w) <= 2 && Math.abs(best.s.height - h) <= 2; return { screen: best.s, exact }; } function srcFor(s: MarkerScreen, mode: Mode) { return (mode === "edge" ? s.edge : s.full) ?? s.full ?? s.edge ?? ""; } function Shape({ w, h, active }: { w: number; h: number; active?: boolean }) { const scale = 34 / Math.max(w, h); return ( ); } // --- Browser-owned state, read via useSyncExternalStore ------------------- // The URL hash says which marker is open (#/ or #), // so share links, refresh and the back button all behave the same way. const HASH_EVENT = "ttvfx-set-hash"; const MODE_EVENT = "ttvfx-set-mode"; function subscribe(events: string[]) { return (cb: () => void) => { events.forEach((e) => window.addEventListener(e, cb)); return () => events.forEach((e) => window.removeEventListener(e, cb)); }; } const subscribeHash = subscribe(["popstate", "hashchange", HASH_EVENT]); const subscribeMode = subscribe(["storage", MODE_EVENT]); const subscribeScreen = subscribe(["resize", "orientationchange"]); function setHash(hash: string, push: boolean) { history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname); window.dispatchEvent(new Event(HASH_EVENT)); } function readMode(): Mode { try { return localStorage.getItem(MODE_KEY) === "edge" ? "edge" : "full"; } catch { return "full"; } } function writeMode(m: Mode) { try { localStorage.setItem(MODE_KEY, m); } catch {} window.dispatchEvent(new Event(MODE_EVENT)); } function screenKey() { const { w, h } = detectScreen(); return w > 0 && h > 0 ? `${w}x${h}` : ""; // some browsers report 0×0 while hidden } function iosNeedsTip() { const ios = /iPhone|iPod/.test(navigator.userAgent); const standalone = window.matchMedia("(display-mode: standalone), (display-mode: fullscreen)").matches; return ios && !standalone; } function requestFullscreen() { if (document.fullscreenEnabled && !document.fullscreenElement) { document.documentElement.requestFullscreen?.().catch(() => {}); } } export default function MarkerPicker({ screens, extras }: { screens: MarkerScreen[]; extras: MarkerExtra[] }) { const hash = useSyncExternalStore(subscribeHash, () => location.hash, () => ""); const mode = useSyncExternalStore(subscribeMode, readMode, () => "full" as Mode); const key = useSyncExternalStore(subscribeScreen, screenKey, () => ""); const iosTip = useSyncExternalStore(() => () => {}, iosNeedsTip, () => false); const [offline, setOffline] = useState<"idle" | "saving" | "ready">("idle"); const device = useMemo(() => { if (!key) return null; const [w, h] = key.split("x").map(Number); return { w, h }; }, [key]); const match = useMemo(() => (device ? bestMatch(screens, device.w, device.h) : null), [device, screens]); // What the viewer shows, derived from the hash const showing = useMemo<(Showing & { mode: Mode }) | null>(() => { const [id, m] = hash.slice(1).split("/"); if (!id) return null; const viewMode: Mode = m === "edge" ? "edge" : "full"; const s = screens.find((x) => x.id === id); if (s) return { src: srcFor(s, viewMode), label: s.name, detail: `${s.width}×${s.height}${s.landscape ? " · landscape" : ""}`, screen: s, mode: viewMode, }; const e = extras.find((x) => x.id === id); return e ? { src: e.src, label: e.name, detail: "", extra: e, mode: viewMode } : null; }, [hash, screens, extras]); // Leave browser full screen whenever the viewer closes (incl. back button) useEffect(() => { if (!showing && document.fullscreenElement) document.exitFullscreen?.().catch(() => {}); }, [showing]); // Offline: register the service worker, then put every marker plus this page's own // scripts/styles/fonts straight into its cache. Writing from the page (rather than // relying on the worker intercepting fetches) also covers the very first visit, // before the worker controls the page. useEffect(() => { if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator) || !("caches" in window)) return; let cancelled = false; (async () => { try { await navigator.serviceWorker.register("/set/sw.js", { scope: "/set/" }); await navigator.serviceWorker.ready; if (cancelled) return; setOffline("saving"); const markers = [...screens.flatMap((s) => [s.full, s.edge]), ...extras.map((e) => e.src)].filter(Boolean) as string[]; const assets = performance .getEntriesByType("resource") .map((e) => new URL(e.name)) .filter((u) => u.origin === location.origin && /^\/(_next\/|icon|apple-icon)/.test(u.pathname)) .map((u) => u.pathname + u.search); const cache = await caches.open(OFFLINE_CACHE); await cache.addAll(["/set/", ...new Set([...markers, ...assets])]); if (!cancelled) setOffline("ready"); } catch { if (!cancelled) setOffline("idle"); } })(); return () => { cancelled = true; }; }, [screens, extras]); const showScreen = (s: MarkerScreen) => { setHash(`#${s.id}/${mode}`, true); requestFullscreen(); }; const showExtra = (e: MarkerExtra) => { setHash(`#${e.id}`, true); requestFullscreen(); }; const close = useCallback(() => { if (history.state?.marker) history.back(); else setHash("", false); }, []); const switchMode = useCallback( (m: Mode) => { writeMode(m); if (showing?.screen) setHash(`#${showing.screen.id}/${m}`, false); }, [showing], ); const chooseMode = writeMode; const grouped = useMemo(() => { const out = new Map(); for (const s of screens) out.set(s.kind, [...(out.get(s.kind) ?? []), s]); return [...out.entries()]; }, [screens]); return (

Two Tales VFX · On set

Screen markers

{offline === "ready" ? "✓ Works offline" : offline === "saving" ? "Saving for offline…" : ""}

{/* Marker style */}
{( [ ["full", "Full grid", "Dots across the whole screen"], ["edge", "Edges only", "Clear centre for actors' hands"], ] as const ).map(([m, label, hint]) => ( ))}
{/* This screen */} {match && device && (

This screen

)} {iosTip && (

iPhone tip: Safari can't hide its bars. For true full screen tap{" "} Share → Add to Home Screen once, then open “Markers” from the home screen.

)} {/* All screens */} {grouped.map(([kind, list]) => (

{KIND_LABEL[kind]}

    {list.map((s) => { const isMatch = match?.screen.id === s.id; return (
  • ); })}
))} {extras.length > 0 && (

Other

    {extras.map((e) => (
  • ))}
)}

Tap the image for controls · Back or Esc to close · Screen stays awake while showing

{showing && ( )}
); } function Viewer({ showing, mode, onMode, onClose, }: { showing: Showing; mode: Mode; onMode: (m: Mode) => void; onClose: () => void; }) { const [controls, setControls] = useState(false); const [fullscreen, setFullscreen] = useState(false); const hideTimer = useRef(undefined); // Stretch to fill when the image's shape is close to the visible area (markers sit on // the real screen edges); otherwise fit it whole so the dots stay round. const viewport = useSyncExternalStore(subscribeScreen, () => `${innerWidth}x${innerHeight}`, () => "1x1"); const fit = useMemo(() => { if (!showing.screen) return "object-cover"; const [vw, vh] = viewport.split("x").map(Number); const ratio = showing.screen.width / showing.screen.height / (vw / vh); return Math.abs(ratio - 1) <= 0.15 ? "object-fill" : "object-contain"; }, [showing.screen, viewport]); const canFullscreen = typeof document !== "undefined" && document.fullscreenEnabled; // Keep the screen awake while a marker is up useEffect(() => { let lock: WakeLockSentinel | null = null; const acquire = async () => { try { lock = (await navigator.wakeLock?.request("screen")) ?? null; } catch {} }; const onVisible = () => document.visibilityState === "visible" && acquire(); acquire(); document.addEventListener("visibilitychange", onVisible); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("visibilitychange", onVisible); document.body.style.overflow = ""; lock?.release().catch(() => {}); }; }, []); useEffect(() => { const onFs = () => setFullscreen(Boolean(document.fullscreenElement)); onFs(); document.addEventListener("fullscreenchange", onFs); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !document.fullscreenElement) onClose(); if (e.key === "f") onMode("full"); if (e.key === "e") onMode("edge"); if (e.key === " ") setControls((v) => !v); }; document.addEventListener("keydown", onKey); return () => { document.removeEventListener("fullscreenchange", onFs); document.removeEventListener("keydown", onKey); }; }, [onClose, onMode]); const poke = () => { window.clearTimeout(hideTimer.current); setControls((v) => { if (!v) hideTimer.current = window.setTimeout(() => setControls(false), 4000); return !v; }); }; return (
{ if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); poke(); }} > {/* eslint-disable-next-line @next/next/no-img-element */} {showing.label} {canFullscreen && !fullscreen && !controls && (

Tap for full screen

)} {controls && (
e.stopPropagation()} > {showing.screen && (
{(["full", "edge"] as const).map((m) => ( ))}
)} {showing.label} {showing.detail && `· ${showing.detail}`} Save
)}
); }