5 changed files with 447 additions and 51 deletions
@ -0,0 +1,17 @@ |
|||||
|
import { EventEmitter } from "events"; |
||||
|
|
||||
|
export interface QRScannerListener { |
||||
|
onScan: (result: string) => void; |
||||
|
onError: (error: Error) => void; |
||||
|
} |
||||
|
|
||||
|
export interface QRScannerService { |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
isSupported(): Promise<boolean>; |
||||
|
startScan(): Promise<void>; |
||||
|
stopScan(): Promise<void>; |
||||
|
addListener(listener: QRScannerListener): void; |
||||
|
cleanup(): Promise<void>; |
||||
|
onStream(callback: (stream: MediaStream | null) => void): void; |
||||
|
} |
@ -0,0 +1,195 @@ |
|||||
|
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
import { EventEmitter } from "events"; |
||||
|
|
||||
|
export class WebInlineQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private stream: MediaStream | null = null; |
||||
|
private events = new EventEmitter(); |
||||
|
|
||||
|
constructor(private options?: QRScannerOptions) {} |
||||
|
|
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.log("[QRScanner] Checking camera permissions..."); |
||||
|
const permissions = await navigator.permissions.query({ |
||||
|
name: "camera" as PermissionName, |
||||
|
}); |
||||
|
logger.log("[QRScanner] Permission state:", permissions.state); |
||||
|
return permissions.state === "granted"; |
||||
|
} catch (error) { |
||||
|
logger.error("[QRScanner] Error checking camera permissions:", error); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async requestPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
// First check if we have any video devices
|
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
const videoDevices = devices.filter( |
||||
|
(device) => device.kind === "videoinput", |
||||
|
); |
||||
|
|
||||
|
if (videoDevices.length === 0) { |
||||
|
logger.error("No video devices found"); |
||||
|
throw new Error("No camera found on this device"); |
||||
|
} |
||||
|
|
||||
|
// Try to get a stream with specific constraints
|
||||
|
const stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Stop the test stream immediately
|
||||
|
stream.getTracks().forEach((track) => track.stop()); |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error requesting camera permissions:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
name: wrappedError.name, |
||||
|
}); |
||||
|
|
||||
|
// Provide more specific error messages
|
||||
|
if ( |
||||
|
wrappedError.name === "NotFoundError" || |
||||
|
wrappedError.name === "DevicesNotFoundError" |
||||
|
) { |
||||
|
throw new Error("No camera found on this device"); |
||||
|
} else if ( |
||||
|
wrappedError.name === "NotAllowedError" || |
||||
|
wrappedError.name === "PermissionDeniedError" |
||||
|
) { |
||||
|
throw new Error( |
||||
|
"Camera access denied. Please grant camera permission and try again", |
||||
|
); |
||||
|
} else if ( |
||||
|
wrappedError.name === "NotReadableError" || |
||||
|
wrappedError.name === "TrackStartError" |
||||
|
) { |
||||
|
throw new Error("Camera is in use by another application"); |
||||
|
} else { |
||||
|
throw new Error(`Camera error: ${wrappedError.message}`); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async isSupported(): Promise<boolean> { |
||||
|
try { |
||||
|
// Check for secure context first
|
||||
|
if (!window.isSecureContext) { |
||||
|
logger.warn("Camera access requires HTTPS (secure context)"); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check for camera API support
|
||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
||||
|
logger.warn("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", |
||||
|
); |
||||
|
|
||||
|
if (!hasVideoDevices) { |
||||
|
logger.warn("No video devices found"); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
logger.error("Error checking camera support:", { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScan(): Promise<void> { |
||||
|
if (this.isScanning) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.isScanning = true; |
||||
|
logger.log("[WebInlineQRScanner] Starting scan"); |
||||
|
|
||||
|
// Get camera stream
|
||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Emit stream to component
|
||||
|
this.events.emit("stream", this.stream); |
||||
|
} catch (error) { |
||||
|
this.isScanning = false; |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError(wrappedError); |
||||
|
} |
||||
|
logger.error("Error starting scan:", wrappedError); |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScan(): Promise<void> { |
||||
|
if (!this.isScanning) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
logger.log("[WebInlineQRScanner] Stopping scan"); |
||||
|
|
||||
|
// Stop all tracks in the stream
|
||||
|
if (this.stream) { |
||||
|
this.stream.getTracks().forEach(track => track.stop()); |
||||
|
this.stream = null; |
||||
|
} |
||||
|
|
||||
|
// Emit stream stopped event
|
||||
|
this.events.emit("stream", null); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error stopping scan:", wrappedError); |
||||
|
throw wrappedError; |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
addListener(listener: ScanListener): void { |
||||
|
this.scanListener = listener; |
||||
|
} |
||||
|
|
||||
|
// Add method to get stream events
|
||||
|
onStream(callback: (stream: MediaStream | null) => void): void { |
||||
|
this.events.on("stream", callback); |
||||
|
} |
||||
|
|
||||
|
async cleanup(): Promise<void> { |
||||
|
try { |
||||
|
await this.stopScan(); |
||||
|
this.events.removeAllListeners(); |
||||
|
} catch (error) { |
||||
|
logger.error("Error during cleanup:", error); |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue