You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

693 lines
22 KiB

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<CameraStateListener> = 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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}
}