4ac1817425
Deploy / deploy (push) Successful in 41s
Supervisors log scene/shot/take, per-camera settings, VFX tags, references and photos on iPhone/iPad; works offline, stored on device (localStorage + IndexedDB for photos). Exports CSV + photos and a .json backup/restore. /set/report/view renders a printable A4 report. "Send report link" carries the day's log compressed in the URL fragment, so post gets a formatted page with Save PDF and nothing is uploaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
25 KiB
TypeScript
636 lines
25 KiB
TypeScript
"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<Kind, string> = { 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 (
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
|
||
<span
|
||
className={`block rounded-[3px] border-2 ${active ? "border-accent bg-accent/20" : "border-white/60"}`}
|
||
style={{ width: Math.max(10, w * scale), height: Math.max(10, h * scale) }}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// --- Browser-owned state, read via useSyncExternalStore -------------------
|
||
// The URL hash says which marker is open (#<screen-id>/<full|edge> or #<extra-id>),
|
||
// 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<Kind, MarkerScreen[]>();
|
||
for (const s of screens) out.set(s.kind, [...(out.get(s.kind) ?? []), s]);
|
||
return [...out.entries()];
|
||
}, [screens]);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 pt-6 pb-24">
|
||
<header className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="font-mono text-[11px] tracking-[0.25em] text-accent uppercase">Two Tales VFX · On set</p>
|
||
<h1 className="mt-1 font-display text-3xl font-light tracking-wide uppercase">Screen markers</h1>
|
||
</div>
|
||
<p className="pt-1 text-right font-mono text-[11px] leading-relaxed text-muted">
|
||
{offline === "ready" ? "✓ Works offline" : offline === "saving" ? "Saving for offline…" : ""}
|
||
</p>
|
||
</header>
|
||
|
||
{/* Marker style */}
|
||
<div className="mt-6 grid grid-cols-2 gap-2 rounded-xl bg-white/5 p-1.5" role="radiogroup" aria-label="Marker style">
|
||
{(
|
||
[
|
||
["full", "Full grid", "Dots across the whole screen"],
|
||
["edge", "Edges only", "Clear centre for actors' hands"],
|
||
] as const
|
||
).map(([m, label, hint]) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={mode === m}
|
||
onClick={() => chooseMode(m)}
|
||
className={`min-h-16 rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||
mode === m ? "bg-fg text-ink" : "text-fg hover:bg-white/5"
|
||
}`}
|
||
>
|
||
<span className="block text-base font-medium">{label}</span>
|
||
<span className={`block text-xs ${mode === m ? "text-ink/70" : "text-muted"}`}>{hint}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Plain grey and other non-device images */}
|
||
{extras.length > 0 && (
|
||
<section className="mt-6">
|
||
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
|
||
{extras.map((e) => (
|
||
<li key={e.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => showExtra(e)}
|
||
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
|
||
<span className="block h-7 w-7 rounded-[3px] bg-[#808080]" />
|
||
</span>
|
||
<span className="flex-1 text-base">{e.name}</span>
|
||
<span className="text-xl text-muted" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
{/* This screen */}
|
||
{match && device && (
|
||
<section className="mt-6">
|
||
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">This screen</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => showScreen(match.screen)}
|
||
className="flex min-h-24 w-full items-center gap-4 rounded-xl border-2 border-accent bg-accent/10 px-4 py-4 text-left active:bg-accent/25"
|
||
>
|
||
<Shape w={match.screen.width} h={match.screen.height} active />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-xl font-medium">{match.screen.name}</span>
|
||
<span className="block font-mono text-xs text-muted">
|
||
{match.screen.width}×{match.screen.height}
|
||
{match.screen.landscape ? " · landscape" : ""} ·{" "}
|
||
{match.exact ? "exact match" : `closest to ${device.w}×${device.h}`}
|
||
</span>
|
||
</span>
|
||
<span className="shrink-0 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-ink">Show</span>
|
||
</button>
|
||
</section>
|
||
)}
|
||
|
||
{iosTip && (
|
||
<p className="mt-4 rounded-lg bg-white/5 px-4 py-3 text-sm leading-relaxed text-muted">
|
||
<span className="text-fg">iPhone tip:</span> Safari can't hide its bars. For true full screen tap{" "}
|
||
<span className="text-fg">Share → Add to Home Screen</span> once, then open “Markers” from the home screen.
|
||
</p>
|
||
)}
|
||
|
||
{/* All screens */}
|
||
{grouped.map(([kind, list]) => (
|
||
<section key={kind} className="mt-8">
|
||
<h2 className="mb-2 font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{KIND_LABEL[kind]}</h2>
|
||
<ul className="divide-y divide-white/10 overflow-hidden rounded-xl bg-white/[0.04]">
|
||
{list.map((s) => {
|
||
const isMatch = match?.screen.id === s.id;
|
||
return (
|
||
<li key={s.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => showScreen(s)}
|
||
className="flex min-h-16 w-full items-center gap-4 px-4 py-3 text-left active:bg-white/10"
|
||
>
|
||
<Shape w={s.width} h={s.height} active={isMatch} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-base">{s.name}</span>
|
||
<span className="block font-mono text-xs text-muted">
|
||
{s.width}×{s.height}
|
||
{s.landscape ? " · landscape" : " · portrait"}
|
||
</span>
|
||
</span>
|
||
{isMatch && <span className="font-mono text-[10px] tracking-widest text-accent uppercase">This</span>}
|
||
<span className="text-xl text-muted" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</section>
|
||
))}
|
||
|
||
<details className="mt-8 rounded-xl bg-white/[0.04] px-4 py-3 text-sm leading-relaxed text-muted">
|
||
<summary className="cursor-pointer py-2 text-fg">Lock the whole device (blocks home swipe & notifications)</summary>
|
||
<p className="mt-2">
|
||
<span className="text-fg">iPhone / iPad:</span> Settings → Accessibility → Guided Access → on. Open a marker,
|
||
triple-click the side (or home) button, turn <span className="text-fg">Touch</span> off in Options, then
|
||
Start. Triple-click again to end.
|
||
</p>
|
||
<p className="mt-2 pb-1">
|
||
<span className="text-fg">Android:</span> Settings → Security → App pinning (Screen pinning) → on. Open a
|
||
marker, open Recents, tap the app icon → Pin. Hold Back + Overview to unpin.
|
||
</p>
|
||
</details>
|
||
|
||
<Link
|
||
href="/set/spill/"
|
||
className="mt-8 flex items-center justify-between rounded-xl bg-white/5 px-4 py-4 transition hover:bg-white/10"
|
||
>
|
||
<span>
|
||
<span className="block font-medium">Spill checker</span>
|
||
<span className="block text-sm text-muted">Point your phone at the green screen setup to find spill</span>
|
||
</span>
|
||
<span aria-hidden className="text-xl text-muted">→</span>
|
||
</Link>
|
||
<Link
|
||
href="/set/report/"
|
||
className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-4 py-4 transition hover:bg-white/10"
|
||
>
|
||
<span>
|
||
<span className="block font-medium">VFX report</span>
|
||
<span className="block text-sm text-muted">Log camera, references and notes for every take</span>
|
||
</span>
|
||
<span aria-hidden className="text-xl text-muted">→</span>
|
||
</Link>
|
||
|
||
<p className="mt-10 text-center text-xs leading-relaxed text-muted">
|
||
Touch is locked while a marker is up · hold the top-left corner 3 s for controls · screen stays awake
|
||
</p>
|
||
|
||
{showing && (
|
||
<Viewer showing={showing} mode={showing.mode} onMode={switchMode} onClose={close} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<number | undefined>(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 (
|
||
<div
|
||
className={`fixed inset-0 z-[100] touch-none bg-black select-none [-webkit-touch-callout:none] ${controls ? "" : "cursor-none"}`}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={cancelHold}
|
||
onPointerCancel={cancelHold}
|
||
onPointerLeave={cancelHold}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img src={showing.src} alt={showing.label} className={`pointer-events-none h-full w-full ${fit}`} draggable={false} />
|
||
|
||
{/* Corner-hold progress ring */}
|
||
{hold > 0 && (
|
||
<svg className="pointer-events-none absolute top-3 left-3 h-14 w-14 -rotate-90" viewBox="0 0 36 36" aria-hidden="true">
|
||
<circle cx="18" cy="18" r="15" fill="rgba(0,0,0,.6)" stroke="rgba(255,255,255,.25)" strokeWidth="3" />
|
||
<circle
|
||
cx="18"
|
||
cy="18"
|
||
r="15"
|
||
fill="none"
|
||
stroke="var(--accent)"
|
||
strokeWidth="3"
|
||
strokeDasharray={`${hold * 94.25} 94.25`}
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
)}
|
||
|
||
{hint && !controls && (
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-[max(1.5rem,env(safe-area-inset-bottom))] flex justify-center px-4">
|
||
<p className="rounded-full bg-black/75 px-4 py-2 text-center font-mono text-xs text-white/85">
|
||
🔒 Touch locked · hold top-left corner 3 s for controls
|
||
{canFullscreen && !fullscreen ? " · tap for full screen" : ""}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{controls && (
|
||
<div
|
||
data-controls
|
||
className="absolute inset-x-0 top-0 flex flex-wrap items-center gap-2 bg-black/85 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3"
|
||
onPointerDown={(e) => {
|
||
e.stopPropagation();
|
||
showControls(); // keep them up while being used
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="min-h-12 rounded-lg bg-white/10 px-4 text-sm font-medium text-fg active:bg-white/20"
|
||
>
|
||
✕ Close
|
||
</button>
|
||
{showing.screen && (
|
||
<div className="flex rounded-lg bg-white/10 p-1">
|
||
{(["full", "edge"] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={() => onMode(m)}
|
||
className={`min-h-10 rounded-md px-3 text-sm ${mode === m ? "bg-fg text-ink" : "text-fg"}`}
|
||
>
|
||
{m === "full" ? "Full grid" : "Edges"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<span className="min-w-0 flex-1 truncate px-1 text-right font-mono text-xs text-white/70">
|
||
{showing.label} {showing.detail && `· ${showing.detail}`}
|
||
</span>
|
||
<a
|
||
href={showing.src}
|
||
download
|
||
className="flex min-h-12 items-center rounded-lg bg-white/10 px-4 text-sm text-fg active:bg-white/20"
|
||
>
|
||
Save
|
||
</a>
|
||
<button
|
||
type="button"
|
||
onClick={lock}
|
||
className="min-h-12 rounded-lg bg-accent px-4 text-sm font-semibold text-ink active:opacity-80"
|
||
>
|
||
🔒 Lock
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|