import { QRScannerService, ScanListener, QRScannerOptions, CameraState, CameraStateListener, } from "./types"; import { logger } from "@/utils/logger"; import { EventEmitter } from "events"; import jsQR from "jsqr"; // Build identifier to help distinguish between builds const BUILD_ID = `build-${Date.now()}`; export class WebInlineQRScanner implements QRScannerService { private scanListener: ScanListener | null = null; private isScanning = false; private stream: MediaStream | null = null; private events = new EventEmitter(); private canvas: HTMLCanvasElement | null = null; private context: CanvasRenderingContext2D | null = null; private video: HTMLVideoElement | null = null; private animationFrameId: number | null = null; private scanAttempts = 0; private lastScanTime = 0; private readonly id: string; private readonly TARGET_FPS = 15; // Target 15 FPS for scanning private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames private lastFrameTime = 0; private cameraStateListeners: Set = new Set(); private currentState: CameraState = "off"; private currentStateMessage?: string; private options: QRScannerOptions; constructor(options?: QRScannerOptions) { // Generate a short random ID for this scanner instance this.id = Math.random().toString(36).substring(2, 8).toUpperCase(); this.options = options ?? {}; logger.error( `[WebInlineQRScanner:${this.id}] Initializing scanner with options:`, { ...this.options, buildId: BUILD_ID, targetFps: this.TARGET_FPS, }, ); // Create canvas and video elements this.canvas = document.createElement("canvas"); this.context = this.canvas.getContext("2d", { willReadFrequently: true }); this.video = document.createElement("video"); this.video.setAttribute("playsinline", "true"); // Required for iOS logger.error( `[WebInlineQRScanner:${this.id}] DOM elements created successfully`, ); } private updateCameraState(state: CameraState, message?: string) { this.currentState = state; this.currentStateMessage = message; this.cameraStateListeners.forEach((listener) => { try { listener.onStateChange(state, message); logger.info( `[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`, { state, message, }, ); } catch (error) { logger.error( `[WebInlineQRScanner:${this.id}] Error in camera state listener:`, error, ); } }); } addCameraStateListener(listener: CameraStateListener): void { this.cameraStateListeners.add(listener); // Immediately notify the new listener of current state listener.onStateChange(this.currentState, this.currentStateMessage); } removeCameraStateListener(listener: CameraStateListener): void { this.cameraStateListeners.delete(listener); } async checkPermissions(): Promise { try { this.updateCameraState("initializing", "Checking camera permissions..."); logger.error( `[WebInlineQRScanner:${this.id}] Checking camera permissions...`, ); // First try the Permissions API if available if (navigator.permissions && navigator.permissions.query) { try { const permissions = await navigator.permissions.query({ name: "camera" as PermissionName, }); logger.error( `[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`, permissions.state, ); if (permissions.state === "granted") { this.updateCameraState("ready", "Camera permissions granted"); return true; } } catch (permError) { // Permissions API might not be supported, continue with getUserMedia check logger.error( `[WebInlineQRScanner:${this.id}] Permissions API not supported:`, permError, ); } } // If Permissions API is not available or didn't return granted, // try a test getUserMedia call try { const testStream = await navigator.mediaDevices.getUserMedia({ video: true, }); // If we get here, we have permission testStream.getTracks().forEach((track) => track.stop()); this.updateCameraState("ready", "Camera permissions granted"); return true; } catch (mediaError) { const error = mediaError as Error; logger.error( `[WebInlineQRScanner:${this.id}] getUserMedia test failed:`, { name: error.name, message: error.message, }, ); if ( error.name === "NotAllowedError" || error.name === "PermissionDeniedError" ) { this.updateCameraState("permission_denied", "Camera access denied"); return false; } // For other errors, we'll try requesting permissions explicitly return false; } } catch (error) { logger.error( `[WebInlineQRScanner:${this.id}] Error checking camera permissions:`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, ); this.updateCameraState("error", "Error checking camera permissions"); return false; } } async requestPermissions(): Promise { try { this.updateCameraState( "initializing", "Requesting camera permissions...", ); logger.error( `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, ); // First check if we have any video devices const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter( (device) => device.kind === "videoinput", ); logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, { count: videoDevices.length, devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })), userAgent: navigator.userAgent, }); if (videoDevices.length === 0) { logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); this.updateCameraState("not_found", "No camera found on this device"); throw new Error("No camera found on this device"); } // Try to get a stream with specific constraints logger.error( `[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`, { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 }, }, ); try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 }, }, }); this.updateCameraState("ready", "Camera permissions granted"); // Stop the test stream immediately stream.getTracks().forEach((track) => { logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { kind: track.kind, label: track.label, readyState: track.readyState, }); track.stop(); }); return true; } catch (mediaError) { const error = mediaError as Error; logger.error( `[WebInlineQRScanner:${this.id}] Error requesting camera access:`, { name: error.name, message: error.message, userAgent: navigator.userAgent, }, ); // Update state based on error type if ( error.name === "NotFoundError" || error.name === "DevicesNotFoundError" ) { this.updateCameraState("not_found", "No camera found on this device"); throw new Error("No camera found on this device"); } else if ( error.name === "NotAllowedError" || error.name === "PermissionDeniedError" ) { this.updateCameraState("permission_denied", "Camera access denied"); throw new Error( "Camera access denied. Please grant camera permission and try again", ); } else if ( error.name === "NotReadableError" || error.name === "TrackStartError" ) { this.updateCameraState( "in_use", "Camera is in use by another application", ); throw new Error("Camera is in use by another application"); } else { this.updateCameraState("error", error.message); throw new Error(`Camera error: ${error.message}`); } } } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); logger.error( `[WebInlineQRScanner:${this.id}] Error in requestPermissions:`, { error: wrappedError.message, stack: wrappedError.stack, userAgent: navigator.userAgent, }, ); throw wrappedError; } } async isSupported(): Promise { try { logger.error( `[WebInlineQRScanner:${this.id}] Checking browser support...`, ); // Check for secure context first if (!window.isSecureContext) { logger.error( `[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`, ); return false; } // Check for camera API support if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { logger.error( `[WebInlineQRScanner:${this.id}] Camera API not supported in this browser`, ); return false; } // Check if we have any video devices const devices = await navigator.mediaDevices.enumerateDevices(); const hasVideoDevices = devices.some( (device) => device.kind === "videoinput", ); logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { hasSecureContext: window.isSecureContext, hasMediaDevices: !!navigator.mediaDevices, hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia, hasVideoDevices, deviceCount: devices.length, }); if (!hasVideoDevices) { logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); return false; } return true; } catch (error) { logger.error( `[WebInlineQRScanner:${this.id}] Error checking camera support:`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, ); return false; } } private async scanQRCode(): Promise { if (!this.video || !this.canvas || !this.context || !this.stream) { logger.error( `[WebInlineQRScanner:${this.id}] Cannot scan: missing required elements`, { hasVideo: !!this.video, hasCanvas: !!this.canvas, hasContext: !!this.context, hasStream: !!this.stream, }, ); return; } try { const now = Date.now(); const timeSinceLastFrame = now - this.lastFrameTime; // Throttle frame processing to target FPS if (timeSinceLastFrame < this.FRAME_INTERVAL) { this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); return; } this.lastFrameTime = now; // Set canvas dimensions to match video this.canvas.width = this.video.videoWidth; this.canvas.height = this.video.videoHeight; // Draw video frame to canvas this.context.drawImage( this.video, 0, 0, this.canvas.width, this.canvas.height, ); // Get image data from canvas const imageData = this.context.getImageData( 0, 0, this.canvas.width, this.canvas.height, ); // Increment scan attempts this.scanAttempts++; const timeSinceLastScan = now - this.lastScanTime; // Log scan attempt every 100 frames or 1 second if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) { logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { attempt: this.scanAttempts, dimensions: { width: this.canvas.width, height: this.canvas.height, }, fps: Math.round(1000 / timeSinceLastScan), imageDataSize: imageData.data.length, imageDataWidth: imageData.width, imageDataHeight: imageData.height, timeSinceLastFrame, targetFPS: this.TARGET_FPS, }); this.lastScanTime = now; } // Scan for QR code const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: "attemptBoth", // Try both normal and inverted }); if (code) { // Check if the QR code is blurry by examining the location points const { topRightCorner, topLeftCorner, bottomLeftCorner } = code.location; const width = Math.sqrt( Math.pow(topRightCorner.x - topLeftCorner.x, 2) + Math.pow(topRightCorner.y - topLeftCorner.y, 2), ); const height = Math.sqrt( Math.pow(bottomLeftCorner.x - topLeftCorner.x, 2) + Math.pow(bottomLeftCorner.y - topLeftCorner.y, 2), ); // Adjust minimum size based on canvas dimensions const minSize = Math.min(this.canvas.width, this.canvas.height) * 0.1; // 10% of the smaller dimension const isBlurry = width < minSize || height < minSize || !code.data || code.data.length === 0; logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { data: code.data, location: code.location, attempts: this.scanAttempts, isBlurry, dimensions: { width, height, minSize, canvasWidth: this.canvas.width, canvasHeight: this.canvas.height, relativeWidth: width / this.canvas.width, relativeHeight: height / this.canvas.height, }, corners: { topLeft: topLeftCorner, topRight: topRightCorner, bottomLeft: bottomLeftCorner, }, }); if (isBlurry) { if (this.scanListener?.onError) { this.scanListener.onError( new Error( "QR code detected but too blurry to read. Please hold the camera steady and ensure the QR code is well-lit.", ), ); } // Continue scanning if QR code is blurry this.animationFrameId = requestAnimationFrame(() => this.scanQRCode(), ); return; } if (this.scanListener?.onScan) { this.scanListener.onScan(code.data); } // Stop scanning after successful detection await this.stopScan(); return; } // Continue scanning if no QR code found this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); } catch (error) { logger.error(`[WebInlineQRScanner:${this.id}] Error scanning QR code:`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, attempt: this.scanAttempts, videoState: this.video ? { readyState: this.video.readyState, paused: this.video.paused, ended: this.video.ended, width: this.video.videoWidth, height: this.video.videoHeight, } : null, canvasState: this.canvas ? { width: this.canvas.width, height: this.canvas.height, } : null, }); if (this.scanListener?.onError) { this.scanListener.onError( error instanceof Error ? error : new Error(String(error)), ); } } } async startScan(options?: QRScannerOptions): Promise { if (this.isScanning) { logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`); return; } // Update options if provided if (options) { this.options = { ...this.options, ...options }; } try { this.isScanning = true; this.scanAttempts = 0; this.lastScanTime = Date.now(); this.updateCameraState("initializing", "Starting camera..."); logger.error( `[WebInlineQRScanner:${this.id}] Starting scan with options:`, this.options, ); // Get camera stream with options logger.error( `[WebInlineQRScanner:${this.id}] Requesting camera stream...`, ); this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: this.options.camera === "front" ? "user" : "environment", width: { ideal: 1280 }, height: { ideal: 720 }, }, }); this.updateCameraState("active", "Camera is active"); logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { tracks: this.stream.getTracks().map((t) => ({ kind: t.kind, label: t.label, readyState: t.readyState, })), options: this.options, }); // Set up video element if (this.video) { this.video.srcObject = this.stream; // Only show preview if showPreview is true if (this.options.showPreview) { this.video.style.display = "block"; } else { this.video.style.display = "none"; } await this.video.play(); logger.error( `[WebInlineQRScanner:${this.id}] Video element started playing`, ); } // Emit stream to component this.events.emit("stream", this.stream); logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`); // Start QR code scanning this.scanQRCode(); } catch (error) { this.isScanning = false; const wrappedError = error instanceof Error ? error : new Error(String(error)); // Update state based on error type if ( wrappedError.name === "NotReadableError" || wrappedError.name === "TrackStartError" ) { this.updateCameraState( "in_use", "Camera is in use by another application", ); } else { this.updateCameraState("error", wrappedError.message); } if (this.scanListener?.onError) { this.scanListener.onError(wrappedError); } throw wrappedError; } } async stopScan(): Promise { if (!this.isScanning) { logger.error( `[WebInlineQRScanner:${this.id}] Scanner not running, nothing to stop`, ); return; } try { logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { scanAttempts: this.scanAttempts, duration: Date.now() - this.lastScanTime, }); // Stop animation frame if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; logger.error( `[WebInlineQRScanner:${this.id}] Animation frame cancelled`, ); } // Stop video if (this.video) { this.video.pause(); this.video.srcObject = null; logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); } // Stop all tracks in the stream if (this.stream) { this.stream.getTracks().forEach((track) => { logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { kind: track.kind, label: track.label, readyState: track.readyState, }); track.stop(); }); this.stream = null; } // Emit stream stopped event this.events.emit("stream", null); logger.error( `[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, ); } catch (error) { logger.error( `[WebInlineQRScanner:${this.id}] Error stopping scan:`, error, ); this.updateCameraState("error", "Error stopping camera"); throw error; } finally { this.isScanning = false; logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); } } addListener(listener: ScanListener): void { logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); this.scanListener = listener; } onStream(callback: (stream: MediaStream | null) => void): void { logger.error( `[WebInlineQRScanner:${this.id}] Adding stream event listener`, ); this.events.on("stream", callback); } async cleanup(): Promise { try { logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); await this.stopScan(); this.events.removeAllListeners(); logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); // Clean up DOM elements if (this.video) { this.video.remove(); this.video = null; logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); } if (this.canvas) { this.canvas.remove(); this.canvas = null; logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); } this.context = null; logger.error( `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, ); } catch (error) { logger.error( `[WebInlineQRScanner:${this.id}] Error during cleanup:`, error, ); this.updateCameraState("error", "Error during cleanup"); throw error; } } }