barcode scanner beginnings

This commit is contained in:
legop3
2026-06-09 14:27:48 -04:00
parent 5dca976f97
commit 5f00d8c715
15 changed files with 616 additions and 134 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ export default function RoverLabel({
'inline-block rounded border border-transparent px-1 py-[1px] font-semibold text-white',
className,
)}
style={{ ...(style || {}), ...(roverNameChromeStyle(resolvedColor, 0.26) || {}) }}
style={{ ...(style || {}), ...(roverNameChromeStyle(resolvedColor, 1) || {}) }}
{...props}
>
{label}
@@ -7,7 +7,7 @@ export default function DisplayChatFeed() {
const displayChatScale = 4.6;
return (
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black p-[0.45vw]">
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black">
<div
className="origin-top-left"
style={{
+2
View File
@@ -12,6 +12,7 @@ import { ChatProvider } from './context/ChatContext.jsx'
import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.jsx'
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')).render(
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/scanner" element={<ScannerApp />} />
</Routes>
</BrowserRouter>
</ChatProvider>
@@ -0,0 +1,8 @@
// Scanner App Root
// Purpose: Exposes the dedicated scanner route as a separate spectator-style app surface.
// Scope: Keeps route wiring thin so scanner behavior remains isolated in ScannerContent.
import ScannerContent from './ScannerContent.jsx';
export default function ScannerAppRoot() {
return <ScannerContent />;
}
@@ -0,0 +1,139 @@
// Scanner Content
// Purpose: Provides the rover-facing IO page for the server-run barcode scanner system.
// Scope: Captures keyboard-style scanner input, emits raw scans, renders server state, and plays local beep/TTS output.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import useScannerSpeech from './useScannerSpeech.js';
const EMPTY_SCANNER_STATE = {
beepAllowed: false,
lastScan: null,
registryError: null,
};
function playSubmitBeep() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
const audioContext = new AudioContextClass();
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const now = audioContext.currentTime;
// The beep is deliberately short and loud because it confirms that the scan
// computer submitted input. It is not tied to success; success/failure comes
// back from the server as the large display text and spoken label.
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(980, now);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.95, now + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(now);
oscillator.stop(now + 0.22);
oscillator.onended = () => {
audioContext.close().catch(() => {});
};
}
export default function ScannerContent() {
const socket = useSocket();
const connected = useSessionSelector((state) => state.connected);
const inputRef = useRef(null);
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
const [focused, setFocused] = useState(false);
useDefaultNickname();
useUserIdentitySync();
useSpectatorMode();
useScannerSpeech(scannerState.lastScan);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
focusInput();
}, [focusInput]);
useEffect(() => {
function handleScannerState(nextState = {}) {
setScannerState({
...EMPTY_SCANNER_STATE,
...(nextState && typeof nextState === 'object' ? nextState : {}),
});
}
socket.on('barcode:state', handleScannerState);
return () => {
socket.off('barcode:state', handleScannerState);
};
}, [socket]);
const submitScan = useCallback(() => {
const code = String(inputRef.current?.value || '').trim();
if (!code) {
focusInput();
return;
}
if (scannerState.beepAllowed) {
playSubmitBeep();
}
// The page intentionally sends only raw scanner text. The server reloads
// the barcode registry, resolves labels/types, applies access policy, and
// broadcasts the display state back to every scanner page.
socket.emit('barcode:scan', { code }, () => {});
inputRef.current.value = '';
focusInput();
}, [focusInput, scannerState.beepAllowed, socket]);
const lastScan = scannerState.lastScan;
const label = lastScan?.label || 'waiting';
const code = lastScan?.code || '';
const statusText = !connected ? 'offline' : focused ? 'scanner ready' : 'click page';
const showCode = Boolean(code && label !== 'waiting');
return (
<main
className="flex min-h-screen cursor-default flex-col items-center justify-center overflow-hidden bg-black px-6 text-center text-white"
onClick={focusInput}
>
<input
ref={inputRef}
aria-label="barcode scanner input"
className="absolute left-0 top-0 h-px w-px opacity-0"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
onBlur={() => setFocused(false)}
onFocus={() => setFocused(true)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
submitScan();
}}
/>
<section className="flex min-h-0 w-full flex-1 flex-col items-center justify-center">
<h1 className="max-w-full break-words text-[6rem] font-black leading-none tracking-normal text-white md:text-[9rem] lg:text-[11rem]">
{label}
</h1>
{showCode ? (
<p className="mt-8 text-[3rem] font-bold leading-none tracking-normal text-white md:text-[4rem]">
{code}
</p>
) : null}
</section>
<footer className="flex h-20 w-full items-center justify-center text-[2rem] font-bold leading-none tracking-normal text-white">
{statusText}
</footer>
</main>
);
}
@@ -0,0 +1,86 @@
// Scanner Speech Hook
// Purpose: Speaks server-provided scan labels on the scanner computer.
// Scope: Keeps browser TTS as a local output device while leaving scan meaning and phrase selection on the server.
import { useEffect, useRef } from 'react';
const SPEECH_START_TIMEOUT_MS = 1500;
const SPEECH_RETRY_DELAY_MS = 700;
export default function useScannerSpeech(scan) {
const retryTimerRef = useRef(null);
const startTimerRef = useRef(null);
const spokenScanKeyRef = useRef(null);
useEffect(() => {
return () => {
window.clearTimeout(retryTimerRef.current);
window.clearTimeout(startTimerRef.current);
};
}, []);
useEffect(() => {
const speechText = String(scan?.speechText || '').trim();
const scanKey = scan?.scannedAt ? `${scan.scannedAt}:${speechText}` : '';
if (!speechText || !scanKey || spokenScanKeyRef.current === scanKey) return undefined;
let cancelled = false;
spokenScanKeyRef.current = scanKey;
function clearSpeechTimers() {
window.clearTimeout(retryTimerRef.current);
window.clearTimeout(startTimerRef.current);
retryTimerRef.current = null;
startTimerRef.current = null;
}
function retryLater() {
if (cancelled) return;
clearSpeechTimers();
retryTimerRef.current = window.setTimeout(() => speakOnce(), SPEECH_RETRY_DELAY_MS);
}
function speakOnce() {
if (cancelled) return;
const synth = window.speechSynthesis;
if (!synth || typeof window.SpeechSynthesisUtterance !== 'function') {
retryLater();
return;
}
clearSpeechTimers();
// Cancelling before retrying prevents stale utterances from piling up if a
// browser reports an error or never fires the expected start callback.
synth.cancel();
const utterance = new window.SpeechSynthesisUtterance(speechText);
utterance.rate = 0.92;
utterance.pitch = 1;
utterance.volume = 1;
utterance.onstart = () => {
window.clearTimeout(startTimerRef.current);
startTimerRef.current = null;
};
utterance.onend = () => {
clearSpeechTimers();
};
utterance.onerror = () => {
retryLater();
};
synth.speak(utterance);
// Some browser/audio states fail without surfacing onerror. A small start
// watchdog keeps the required scanner audio from silently dying, while the
// UI remains clean and rover-readable.
startTimerRef.current = window.setTimeout(() => {
synth.cancel();
retryLater();
}, SPEECH_START_TIMEOUT_MS);
}
speakOnce();
return () => {
cancelled = true;
clearSpeechTimers();
};
}, [scan]);
}