diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx index 2433cea..ea986f1 100644 --- a/src/app/scan/page.tsx +++ b/src/app/scan/page.tsx @@ -68,6 +68,23 @@ type Resolved = { type Toast = { kind: "ok" | "err"; text: string } | null; +/** + * Minimal shape of a Screen Wake Lock sentinel. The DOM lib types + * this, but declaring it locally keeps the page compiling even where + * the lib is older than the API and the types are absent. + */ +type WakeLockSentinelLike = { + release: () => Promise; + addEventListener?: (type: "release", listener: () => void) => void; +}; + +/** + * How long the same delivery note is ignored after a hit. The camera + * stays pointed at the paper, so without this the same code re-fires + * every frame. + */ +const DEBOUNCE_MS = 3000; + const STATUS_COPY: Record = { draft: "Draft — not yet loaded", sent: "Sent to customer", @@ -84,6 +101,10 @@ export default function ScanPage() { const streamRef = React.useRef(null); const abortRef = React.useRef(null); const fileRef = React.useRef(null); + // Held while the camera is live so the phone does not sleep mid-scan, + // and re-taken when the tab comes back to the foreground (the + // browser drops the lock whenever the page is hidden). + const wakeRef = React.useRef(null); const [support, setSupport] = React.useState<{ kind: QrDetectorKind; @@ -95,6 +116,9 @@ export default function ScanPage() { const [note, setNote] = React.useState(null); const [busy, setBusy] = React.useState(null); const [toast, setToast] = React.useState(null); + // Last code we accepted, and when — so holding the camera on one + // note for 3s does not fire the lookup over and over. + const lastCodeRef = React.useRef<{ code: string; at: number } | null>(null); const [poDraft, setPoDraft] = React.useState(""); const [showPoBox, setShowPoBox] = React.useState(false); @@ -108,6 +132,7 @@ export default function ScanPage() { () => () => { abortRef.current?.abort(); streamRef.current?.getTracks().forEach((t) => t.stop()); + void wakeRef.current?.release(); }, [], ); @@ -117,14 +142,49 @@ export default function ScanPage() { window.setTimeout(() => setToast(null), 6000); }, []); + const requestWakeLock = React.useCallback(async () => { + try { + const nav = navigator as Navigator & { + wakeLock?: { request: (t: "screen") => Promise }; + }; + if (!nav.wakeLock) return; + const sentinel = await nav.wakeLock.request("screen"); + wakeRef.current = sentinel; + // Released automatically when the tab hides; nothing to clean up + // beyond dropping our reference. + sentinel.addEventListener?.("release", () => { + if (wakeRef.current === sentinel) wakeRef.current = null; + }); + + } catch { + // Denied or unsupported — the scan still works, the screen may + // just dim. Not worth interrupting the operator for. + } + }, []); + const stopCamera = React.useCallback(() => { abortRef.current?.abort(); abortRef.current = null; streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; setCameraOn(false); + void wakeRef.current?.release(); + wakeRef.current = null; }, []); + // Browsers drop the screen wake lock whenever the page is hidden, so + // take it again on the way back in — otherwise the phone locks + // mid-queue at the warehouse. + React.useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible" && streamRef.current) { + void requestWakeLock(); + } + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, [requestWakeLock]); + const api = React.useCallback( async (path: string, init?: RequestInit) => { const token = getPocketBase().authStore.token; @@ -156,6 +216,10 @@ export default function ScanPage() { const lookup = React.useCallback( async (number: string) => { + const now = Date.now(); + const last = lastCodeRef.current; + if (last && last.code === number && now - last.at < DEBOUNCE_MS) return; + lastCodeRef.current = { code: number, at: now }; setBusy("lookup"); try { const data = await api( @@ -164,7 +228,11 @@ export default function ScanPage() { setNote(data as Resolved); setPoDraft((data as Resolved).customer_po ?? ""); setShowPoBox(false); - stopCamera(); + // The camera deliberately KEEPS RUNNING. The operator often has + // a stack of notes; scanning the next one should not need a + // tap. The note card renders over the viewfinder, and + // "Scan another" is no longer needed — but a stale frame of + // the same paper must not re-fire, hence the debounce above. flash("ok", `${number} loaded`); } catch (err) { const msg = err instanceof Error ? err.message : "Lookup failed"; @@ -174,7 +242,7 @@ export default function ScanPage() { setBusy(null); } }, - [api, flash, stopCamera], + [api, flash], ); const startCamera = React.useCallback(async () => { @@ -196,6 +264,7 @@ export default function ScanPage() { video.setAttribute("playsinline", "true"); await video.play(); setCameraOn(true); + void requestWakeLock(); const controller = new AbortController(); abortRef.current = controller; void scanVideoFrames( @@ -263,7 +332,48 @@ export default function ScanPage() { ) : null} - {/* ---- the note card, or the scanner ---- */} + {/* + ONE