const CAMERA_GRANTED_KEY = 'fieldlens.camera-granted' const DEFAULT_RELEASE_DELAY_MS = 8_000 export type CameraPermissionState = PermissionState | 'unsupported' let sharedStream: MediaStream | null = null let releaseTimer: number | null = null function cancelScheduledRelease() { if (releaseTimer !== null) { window.clearTimeout(releaseTimer) releaseTimer = null } } function liveVideoTrack(stream: MediaStream | null) { return stream?.getVideoTracks().find((track) => track.readyState === 'live') ?? null } function stopStream(stream: MediaStream | null) { stream?.getTracks().forEach((track) => track.stop()) } export async function readCameraPermission(): Promise { if (!navigator.permissions?.query) return 'unsupported' try { const status = await navigator.permissions.query({ name: 'camera' as PermissionName }) return status.state } catch { return 'unsupported' } } export function rememberCameraGrant() { try { window.localStorage.setItem(CAMERA_GRANTED_KEY, '1') } catch { // Private browsing/storage policy should not prevent camera use. } } export function hasRememberedCameraGrant() { try { return window.localStorage.getItem(CAMERA_GRANTED_KEY) === '1' } catch { return false } } export function isIosDevice(userAgent = navigator.userAgent, platform = navigator.platform, maxTouchPoints = navigator.maxTouchPoints) { return /iPad|iPhone|iPod/.test(userAgent) || (platform === 'MacIntel' && maxTouchPoints > 1) } export function isStandaloneDisplay() { return window.matchMedia('(display-mode: standalone)').matches || Boolean((navigator as Navigator & { standalone?: boolean }).standalone) } export function shouldAutoStartCamera(permission: CameraPermissionState) { if (permission === 'granted') return true if (permission === 'denied' || permission === 'prompt') return false // Safari/WebKit does not consistently expose camera through Permissions API. // Reuse a previous grant in normal Safari, but require an explicit tap after a // fresh iOS Home Screen PWA launch to avoid surprise permission prompts. return hasRememberedCameraGrant() && !(isIosDevice() && isStandaloneDisplay()) } export function cameraPermissionHelp() { if (isIosDevice()) { return 'Allow Camera for FieldLens in Safari website settings, then return here and retry.' } if (/Android/i.test(navigator.userAgent)) { return 'In Chrome, open Site settings → Camera → Allow, then return here and retry.' } return 'Allow camera access for this site in your browser settings, then retry.' } export async function acquireCameraStream(deviceId = '') { cancelScheduledRelease() const existingTrack = liveVideoTrack(sharedStream) if (existingTrack) { const currentDeviceId = existingTrack.getSettings().deviceId ?? '' if (!deviceId || !currentDeviceId || currentDeviceId === deviceId) { return sharedStream! } stopStream(sharedStream) sharedStream = null } const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: deviceId ? { deviceId: { exact: deviceId } } : { facingMode: { ideal: 'environment' } }, }) sharedStream = stream rememberCameraGrant() const track = liveVideoTrack(stream) if (track) { track.addEventListener('ended', () => { if (sharedStream === stream) sharedStream = null }, { once: true }) } return stream } export function releaseCameraStream(delayMs = DEFAULT_RELEASE_DELAY_MS) { cancelScheduledRelease() if (!sharedStream) return releaseTimer = window.setTimeout(() => { stopStream(sharedStream) sharedStream = null releaseTimer = null }, delayMs) } export function stopCameraStream() { cancelScheduledRelease() stopStream(sharedStream) sharedStream = null } export function getCameraTrack(stream: MediaStream | null) { return liveVideoTrack(stream) } export function cameraTrackHasTorch(track: MediaStreamTrack | null) { if (!track?.getCapabilities) return false const capabilities = track.getCapabilities() as MediaTrackCapabilities & { torch?: boolean } return Boolean(capabilities.torch) } export async function setCameraTorch(track: MediaStreamTrack | null, enabled: boolean) { if (!track) throw new Error('No active camera track') await track.applyConstraints({ advanced: [{ torch: enabled } as MediaTrackConstraintSet], }) }