[Back] @extends('layout')
@section('title')
<title>Scan QR</title>
@endsection
@section('content')
<section class="container py-4">
<h1 class="h4 mb-2">Scan Checkpoint QR</h1>
<p class="text-muted mb-3">
Point your camera at the QR on the sign. Voice guidance will play automatically after we update your location.
</p>
<div id="reader" style="width:100%; max-width:520px; aspect-ratio: 1 / 1; background:#f8f9fa; border-radius:12px; position: relative; overflow: hidden;"></div>
<div id="scan-status" class="small text-muted mt-2"></div>
<div class="mt-3 d-flex gap-2">
<a href="{{ url()->previous() ?: route('map.show','floor1') }}" class="btn btn-outline-secondary">Back</a>
</div>
</section>
<script src="{{ asset('vendor/html5-qrcode/html5-qrcode.min.js') }}"></script>
<script>
(function() {
const statusEl = document.getElementById('scan-status');
const readerId = 'reader';
const LS_KEY_CAMERA = 'qr_preferred_camera_id';
function setStatus(msg, isErr=false) {
statusEl.textContent = msg || '';
statusEl.classList.toggle('text-danger', !!isErr);
}
// Secure-origin check (required by getUserMedia)
const isSecure = location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
if (!isSecure) {
setStatus('Camera requires HTTPS or http://localhost. Open this as http://localhost:8000 or use HTTPS (Valet/ngrok).', true);
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('This browser does not support camera access (getUserMedia). Try a modern browser.', true);
return;
}
// Redirect helper
function go(url) {
sessionStorage.setItem('map_voice_unlocked', '1');
localStorage.setItem('map_voice_enabled', '1');
try {
const u = new URL(url, window.location.origin);
if (!u.searchParams.has('s')) u.searchParams.set('s','1');
window.location.href = u.toString();
} catch { window.location.href = url; }
}
// Decode handler
function onScanSuccess(text) {
// Stop the scanner once we got a code (prevents double redirects)
try { html5Scanner?.clear(); } catch {}
try { html5Instance?.stop().then(()=>{}); } catch {}
// Full URL or slug support
try { const u = new URL(text); go(u.toString()); return; } catch {}
let path = text.trim();
if (/^https?:/i.test(path)) { go(path); return; }
const [slugPart, queryPart] = path.split('?');
let url = new URL("{{ route('nav.qr', ['slug'=>'__SLUG__']) }}".replace('__SLUG__',''));
url.pathname += encodeURIComponent(slugPart);
if (queryPart) {
for (const kv of queryPart.split('&')) {
const [k,v] = kv.split('=');
if (k) url.searchParams.set(k, v ?? '');
}
}
url.searchParams.set('s','1');
go(url.toString());
}
function onScanFailure(_err) { /* per-frame decode errors are normal */ }
let html5Scanner = null; // Html5QrcodeScanner UI
let html5Instance = null; // Html5Qrcode direct instance (for cameraId fast start)
// Overlay button shown only when we NEED a user gesture
function showStartOverlay(startFn) {
const host = document.getElementById(readerId);
const overlay = document.createElement('button');
overlay.type = 'button';
overlay.className = 'btn btn-primary';
overlay.textContent = 'Start scanner';
Object.assign(overlay.style, {
position: 'absolute', inset: '0',
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem',
border: 'none',
borderRadius: getComputedStyle(host).borderRadius || '12px',
background: 'rgba(0,123,255,0.95)',
color: '#fff'
});
overlay.addEventListener('click', async () => {
overlay.remove();
await startFn();
}, { once: true });
host.appendChild(overlay);
}
async function startScannerUi() {
setStatus('Starting scanner…');
html5Scanner = new Html5QrcodeScanner(readerId, {
fps: 10,
qrbox: 300,
rememberLastUsedCamera: true
}, false);
html5Scanner.render(onScanSuccess, onScanFailure);
setTimeout(() => {
if (!document.querySelector('#' + readerId + ' video')) {
setStatus('If you don’t see the camera prompt, allow camera access in the browser/site settings and reload.', true);
} else {
setStatus('');
}
}, 1200);
}
async function startScannerFast(cameraId) {
// Start directly with a specific deviceId (fastest path, no UI chrome)
setStatus('Starting camera…');
html5Instance = new Html5Qrcode(readerId);
try {
await html5Instance.start(
cameraId,
{ fps: 10, qrbox: 300, aspectRatio: 1.0 },
onScanSuccess,
onScanFailure
);
setStatus('');
} catch (e) {
// Fallback to UI if deviceId fails (device unplugged/renamed)
try { await html5Instance.clear(); } catch {}
html5Instance = null;
await startScannerUi();
}
}
async function pickPreferredOrEnvAndStart() {
// If we stored a cameraId, try that first for instant start
const savedId = localStorage.getItem(LS_KEY_CAMERA);
try {
const cameras = await Html5Qrcode.getCameras();
if (cameras?.length) {
// Prefer saved device; else prefer environment/back
const env = cameras.find(c => /back|rear|environment/i.test(c.label)) || cameras[0];
const chosen = cameras.find(c => c.id === savedId) || env;
localStorage.setItem(LS_KEY_CAMERA, chosen.id); // store for next time
return startScannerFast(chosen.id);
}
} catch (_) {
// If listing cameras fails (permissions), fall back to UI (it will prompt)
}
return startScannerUi();
}
// After user uses the UI once, capture the deviceId and remember it
window.addEventListener('cameraSelection', async (ev) => {
// Some browsers dispatch a devicechange; html5-qrcode doesn’t emit a public event.
// We’ll also try to read the active device after UI starts:
try {
const cams = await Html5Qrcode.getCameras();
const env = cams.find(c => /back|rear|environment/i.test(c.label)) || cams[0];
if (env) localStorage.setItem(LS_KEY_CAMERA, env.id);
} catch {}
});
// === Permission strategy ===
(async () => {
// If permission already granted → auto-start with preferred/env camera (no prompt)
try {
const perm = await (navigator.permissions?.query({ name: 'camera' }) ?? Promise.reject());
if (perm.state === 'granted') {
pickPreferredOrEnvAndStart();
return;
}
if (perm.state === 'prompt') {
// We need a user gesture; show one big button
showStartOverlay(() => startScannerUi());
setStatus('Tap “Start scanner” to allow camera.');
return;
}
// denied
setStatus('Camera permission is blocked. Enable it in your browser/site settings, then reload.', true);
return;
} catch {
// Permissions API not available (iOS older Safari). Try fast path; if it needs a gesture, show overlay.
try {
await pickPreferredOrEnvAndStart();
} catch {
showStartOverlay(() => startScannerUi());
setStatus('Tap “Start scanner” to allow camera.');
}
}
})();
})();
</script>
<style>
/* Make the reader container responsive while keeping max-width */
#reader {
display: block;
width: 100%;
max-width: 520px;
aspect-ratio: 1 / 1;
background: #f8f9fa;
border-radius: 12px;
position: relative;
overflow: hidden;
}
/* Force the injected video / canvas to fully cover the reader */
#reader video,
#reader canvas,
#reader .html5-qrcode-video,
#reader .html5-qrcode-canvas {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
/* Some internal wrappers the library creates — ensure they don't limit size */
#reader .html5-qrcode-placeholder,
#reader .html5-qrcode-root,
#reader .html5-qrcode-region {
width: 100% !important;
height: 100% !important;
}
/* Keep overlay/start button full size and visible on small screens */
#reader > button,
#reader .btn {
width: 100%;
height: 100%;
font-size: 1.05rem;
border-radius: 12px;
}
/* Small-screen tweak */
@media (max-width: 576px) {
#reader .btn { font-size: 1rem; }
}
</style>
@endsection