"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; 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"]); // History entries pushed while the viewer is open. While it is open, Back is trapped // (a stray press of Android's back button must not drop the marker mid-take), so // closing has to unwind all of them. let viewerDepth = 0; let closingViewer = false; function setHash(hash: string, push: boolean) { if (push) viewerDepth++; history[push ? "pushState" : "replaceState"]({ marker: Boolean(hash) }, "", hash || location.pathname); window.dispatchEvent(new Event(HASH_EVENT)); } function closeViewer() { closingViewer = true; if (viewerDepth > 0) { const n = viewerDepth; viewerDepth = 0; history.go(-n); // the popstate handler below finishes the job } else { closingViewer = false; setHash("", false); } } if (typeof window !== "undefined") { window.addEventListener("popstate", () => { if (!closingViewer) return; closingViewer = false; // Opened from a direct link: the entry we land on still carries the hash if (location.hash) setHash("", false); }); } 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(() => closeViewer(), []); 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]) => ( ))}
{/* Plain grey and other non-device images */} {extras.length > 0 && (
    {extras.map((e) => (
  • ))}
)} {/* 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 (
  • ); })}
))}
Lock the whole device (blocks home swipe & notifications)

iPhone / iPad: Settings → Accessibility → Guided Access → on. Open a marker, triple-click the side (or home) button, turn Touch off in Options, then Start. Triple-click again to end.

Android: Settings → Security → App pinning (Screen pinning) → on. Open a marker, open Recents, tap the app icon → Pin. Hold Back + Overview to unpin.

Spill checker Point your phone at the green screen setup to find spill → Plate comp Line up the shot against its background plate → VFX report Log camera, references and notes for every take →

Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake

{showing && ( )}
); } const HOLD_MS = 3000; // how long to hold the corner to unlock const HOLD_SHOW_MS = 500; // ring only appears after this, so brushes show nothing const CORNER = 80; // px, top-left hot zone const CONTROLS_MS = 6000; // controls re-lock after this function Viewer({ showing, mode, onMode, onClose, }: { showing: Showing; mode: Mode; onMode: (m: Mode) => void; onClose: () => void; }) { // Touch-locked by default: only a 3 s hold in the top-left corner shows controls. const [controls, setControls] = useState(false); const [hold, setHold] = useState(0); // 0..1 progress of the corner hold const [hint, setHint] = useState(true); const [fullscreen, setFullscreen] = useState(false); const hideTimer = useRef(undefined); const holdRef = useRef<{ id: number; raf: number } | null>(null); // 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; const showControls = useCallback(() => { window.clearTimeout(hideTimer.current); setControls(true); setHint(false); hideTimer.current = window.setTimeout(() => setControls(false), CONTROLS_MS); }, []); const lock = () => { window.clearTimeout(hideTimer.current); setControls(false); }; // Keep the screen awake while a marker is up useEffect(() => { let wake: WakeLockSentinel | null = null; const acquire = async () => { try { wake = (await navigator.wakeLock?.request("screen")) ?? null; } catch {} }; const onVisible = () => document.visibilityState === "visible" && acquire(); acquire(); document.addEventListener("visibilitychange", onVisible); return () => { document.removeEventListener("visibilitychange", onVisible); wake?.release().catch(() => {}); }; }, []); // Swallow everything the browser would otherwise do with stray input: // scrolling, pull-to-refresh, pinch/double-tap zoom, long-press menus, Back. useEffect(() => { const root = document.documentElement; const prev = { overflow: document.body.style.overflow, overscroll: root.style.overscrollBehavior }; document.body.style.overflow = "hidden"; root.style.overscrollBehavior = "none"; const block = (e: Event) => e.preventDefault(); const blockTouch = (e: TouchEvent) => { if (!(e.target as Element).closest?.("[data-controls]")) e.preventDefault(); }; document.addEventListener("touchmove", blockTouch, { passive: false }); document.addEventListener("gesturestart", block); // iOS pinch document.addEventListener("contextmenu", block); document.addEventListener("dblclick", block); // Guard entry so Back stays on the marker; re-armed every time it is used viewerDepth++; history.pushState({ marker: true }, "", location.hash); const onPop = () => { if (closingViewer || !location.hash) return; history.pushState({ marker: true }, "", location.hash); }; window.addEventListener("popstate", onPop); const hintTimer = window.setTimeout(() => setHint(false), 4000); return () => { document.body.style.overflow = prev.overflow; root.style.overscrollBehavior = prev.overscroll; document.removeEventListener("touchmove", blockTouch); document.removeEventListener("gesturestart", block); document.removeEventListener("contextmenu", block); document.removeEventListener("dblclick", block); window.removeEventListener("popstate", onPop); window.clearTimeout(hintTimer); }; }, []); useEffect(() => { const onFs = () => setFullscreen(Boolean(document.fullscreenElement)); document.addEventListener("fullscreenchange", onFs); const onKey = (e: KeyboardEvent) => { // Locked: keys do nothing (a prop laptop keyboard may get typed on mid-take). // The browser itself still leaves full screen on Esc; that cannot be blocked. if (!controls) { e.preventDefault(); return; } if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); return () => { document.removeEventListener("fullscreenchange", onFs); document.removeEventListener("keydown", onKey); }; }, [controls, onClose]); // --- corner hold ------------------------------------------------------- const cancelHold = () => { if (holdRef.current) cancelAnimationFrame(holdRef.current.raf); holdRef.current = null; setHold(0); }; const onPointerDown = (e: React.PointerEvent) => { if (canFullscreen && !document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); if (controls || e.clientX > CORNER || e.clientY > CORNER) return; const start = performance.now(); const tick = () => { if (!holdRef.current) return; const t = performance.now() - start; setHold(t < HOLD_SHOW_MS ? 0 : Math.min(1, (t - HOLD_SHOW_MS) / (HOLD_MS - HOLD_SHOW_MS))); if (t >= HOLD_MS) { cancelHold(); showControls(); return; } holdRef.current.raf = requestAnimationFrame(tick); }; holdRef.current = { id: e.pointerId, raf: requestAnimationFrame(tick) }; }; const onPointerMove = (e: React.PointerEvent) => { if (holdRef.current?.id === e.pointerId && (e.clientX > CORNER * 1.5 || e.clientY > CORNER * 1.5)) cancelHold(); }; return (
{/* eslint-disable-next-line @next/next/no-img-element */} {showing.label} {/* Corner-hold progress ring */} {hold > 0 && ( )} {hint && !controls && (

🔒 Touch locked · hold top-left corner 3 s for controls {canFullscreen && !fullscreen ? " · tap for full screen" : ""}

)} {controls && (
{ e.stopPropagation(); showControls(); // keep them up while being used }} > {showing.screen && (
{(["full", "edge"] as const).map((m) => ( ))}
)} {showing.label} {showing.detail && `· ${showing.detail}`} Save
)}
); }