@ -1,43 +1,104 @@
import { QRScannerService , ScanListener , QRScannerOptions } 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 ;
constructor ( private options? : QRScannerOptions ) { }
constructor ( private options? : QRScannerOptions ) {
// Generate a short random ID for this scanner instance
this . id = Math . random ( ) . toString ( 36 ) . substring ( 2 , 8 ) . toUpperCase ( ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Initializing scanner with options: ` ,
{
. . . 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 ` ,
) ;
}
async checkPermissions ( ) : Promise < boolean > {
try {
logger . log ( "[QRScanner] Checking camera permissions..." ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Checking camera permissions... ` ,
) ;
const permissions = await navigator . permissions . query ( {
name : "camera" as PermissionName ,
} ) ;
logger . log ( "[QRScanner] Permission state:" , permissions . state ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Permission state: ` ,
permissions . state ,
) ;
return permissions . state === "granted" ;
} catch ( error ) {
logger . error ( "[QRScanner] Error checking camera permissions:" , 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 ,
} ,
) ;
return false ;
}
}
async requestPermissions ( ) : Promise < boolean > {
try {
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 } ) ) ,
} ) ;
if ( videoDevices . length === 0 ) {
logger . error ( "No video devices found" ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] No video devices found ` ) ;
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 } ,
} ,
) ;
const stream = await navigator . mediaDevices . getUserMedia ( {
video : {
facingMode : "environment" ,
@ -46,17 +107,31 @@ export class WebInlineQRScanner implements QRScannerService {
} ,
} ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Camera stream obtained successfully ` ,
) ;
// Stop the test stream immediately
stream . getTracks ( ) . forEach ( ( track ) = > track . stop ( ) ) ;
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 ( 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 ,
} ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Error requesting camera permissions: ` ,
{
error : wrappedError.message ,
stack : wrappedError.stack ,
name : wrappedError.name ,
} ,
) ;
// Provide more specific error messages
if (
@ -84,15 +159,20 @@ export class WebInlineQRScanner implements QRScannerService {
async isSupported ( ) : Promise < boolean > {
try {
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Checking browser support... ` ) ;
// Check for secure context first
if ( ! window . isSecureContext ) {
logger . warn ( "Camera access requires HTTPS (secure context)" ) ;
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 . warn ( "Camera API not supported in this browser" ) ;
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Camera API not supported in this browser ` ,
) ;
return false ;
}
@ -102,31 +182,200 @@ export class WebInlineQRScanner implements QRScannerService {
( 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 . warn ( "No video devices found" ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] No video devices found ` ) ;
return false ;
}
return true ;
} catch ( error ) {
logger . error ( "Error checking camera support:" , {
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
} ) ;
return false ;
if ( this . scanListener ? . onError ) {
this . scanListener . onError (
error instanceof Error ? error : new Error ( String ( error ) ) ,
) ;
}
}
}
async startScan ( ) : Promise < void > {
if ( this . isScanning ) {
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Scanner already running ` ) ;
return ;
}
try {
this . isScanning = true ;
logger . log ( "[WebInlineQRScanner] Starting scan" ) ;
this . scanAttempts = 0 ;
this . lastScanTime = Date . now ( ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Starting scan ` ) ;
// Get camera stream
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Requesting camera stream... ` ) ;
this . stream = await navigator . mediaDevices . getUserMedia ( {
video : {
facingMode : "environment" ,
@ -135,61 +384,143 @@ export class WebInlineQRScanner implements QRScannerService {
} ,
} ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Camera stream obtained: ` , {
tracks : this.stream.getTracks ( ) . map ( ( t ) = > ( {
kind : t.kind ,
label : t.label ,
readyState : t.readyState ,
} ) ) ,
} ) ;
// Set up video element
if ( this . video ) {
this . video . srcObject = this . stream ;
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 ) ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Error starting scan: ` , {
error : wrappedError.message ,
stack : wrappedError.stack ,
name : wrappedError.name ,
} ) ;
if ( this . scanListener ? . onError ) {
this . scanListener . onError ( wrappedError ) ;
}
logger . error ( "Error starting scan:" , wrappedError ) ;
throw wrappedError ;
}
}
async stopScan ( ) : Promise < void > {
if ( ! this . isScanning ) {
logger . error (
` [WebInlineQRScanner: ${ this . id } ] Scanner not running, nothing to stop ` ,
) ;
return ;
}
try {
logger . log ( "[WebInlineQRScanner] Stopping scan" ) ;
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 ) = > track . stop ( ) ) ;
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 ) {
const wrappedError =
error instanceof Error ? error : new Error ( String ( error ) ) ;
logger . error ( "Error stopping scan:" , wrappedError ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Error stopping scan: ` , {
error : wrappedError.message ,
stack : wrappedError.stack ,
name : wrappedError.name ,
} ) ;
throw wrappedError ;
} 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 ;
}
// Add method to get stream events
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 ( "Error during cleanup:" , error ) ;
logger . error ( ` [WebInlineQRScanner: ${ this . id } ] Error during cleanup: ` , {
error : error instanceof Error ? error.message : String ( error ) ,
stack : error instanceof Error ? error.stack : undefined ,
} ) ;
}
}
}