feat: implement comprehensive camera state management

- Add CameraState type and CameraStateListener interface for standardized state handling
- Implement camera state tracking in WebInlineQRScanner:
  - Add state management properties and methods
  - Update state transitions during camera operations
  - Add proper error state handling for different scenarios
- Enhance QR scanner UI with improved state feedback:
  - Add color-coded status indicators
  - Implement state-specific messages and notifications
  - Add user-friendly error notifications for common issues
- Improve error handling with specific states for:
  - Camera in use by another application
  - Permission denied
  - Camera not found
  - General errors

This change improves the user experience by providing clear visual
feedback about the camera's state and better error handling with
actionable notifications.
This commit is contained in:
Matt Raymer
2025-05-19 04:40:18 -04:00
parent d137093aa9
commit 00bb6a5ffa
3 changed files with 165 additions and 100 deletions

View File

@@ -1,4 +1,4 @@
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
import { QRScannerService, ScanListener, QRScannerOptions, CameraState, CameraStateListener } from "./types";
import { logger } from "@/utils/logger";
import { EventEmitter } from "events";
import jsQR from "jsqr";
@@ -21,6 +21,9 @@ export class WebInlineQRScanner implements QRScannerService {
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;
constructor(private options?: QRScannerOptions) {
// Generate a short random ID for this scanner instance
@@ -43,8 +46,35 @@ export class WebInlineQRScanner implements QRScannerService {
);
}
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...`,
);
@@ -55,7 +85,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Permission state:`,
permissions.state,
);
return permissions.state === "granted";
const granted = permissions.state === "granted";
this.updateCameraState(granted ? 'ready' : 'permission_denied');
return granted;
} catch (error) {
logger.error(
`[WebInlineQRScanner:${this.id}] Error checking camera permissions:`,
@@ -64,12 +96,14 @@ export class WebInlineQRScanner implements QRScannerService {
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...`,
);
@@ -107,10 +141,8 @@ export class WebInlineQRScanner implements QRScannerService {
},
});
logger.error(
`[WebInlineQRScanner:${this.id}] Camera stream obtained successfully`,
);
this.updateCameraState('ready', 'Camera permissions granted');
// Stop the test stream immediately
stream.getTracks().forEach((track) => {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
@@ -122,36 +154,20 @@ export class WebInlineQRScanner implements QRScannerService {
});
return true;
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error(
`[WebInlineQRScanner:${this.id}] 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"
) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
// Update state based on error type
if (wrappedError.name === "NotFoundError" || wrappedError.name === "DevicesNotFoundError") {
this.updateCameraState('not_found', 'No camera found on this device');
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"
) {
} else if (wrappedError.name === "NotAllowedError" || wrappedError.name === "PermissionDeniedError") {
this.updateCameraState('permission_denied', 'Camera access denied');
throw new Error("Camera access denied. Please grant camera permission and try again");
} else if (wrappedError.name === "NotReadableError" || wrappedError.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', wrappedError.message);
throw new Error(`Camera error: ${wrappedError.message}`);
}
}
@@ -390,6 +406,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.isScanning = true;
this.scanAttempts = 0;
this.lastScanTime = Date.now();
this.updateCameraState('initializing', 'Starting camera...');
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`);
// Get camera stream
@@ -404,6 +421,8 @@ export class WebInlineQRScanner implements QRScannerService {
},
});
this.updateCameraState('active', 'Camera is active');
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
tracks: this.stream.getTracks().map((t) => ({
kind: t.kind,
@@ -429,13 +448,15 @@ export class WebInlineQRScanner implements QRScannerService {
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,
});
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);
}
@@ -492,14 +513,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
);
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error(`[WebInlineQRScanner:${this.id}] Error stopping scan:`, {
error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name,
});
throw wrappedError;
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`);
@@ -541,10 +557,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
);
} catch (error) {
logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, error);
this.updateCameraState('error', 'Error during cleanup');
throw error;
}
}
}

View File

@@ -22,6 +22,20 @@ export interface QRScannerOptions {
playSound?: boolean;
}
export type CameraState =
| 'initializing' // Camera is being initialized
| 'ready' // Camera is ready to use
| 'active' // Camera is actively streaming
| 'in_use' // Camera is in use by another application
| 'permission_denied' // Camera permission was denied
| 'not_found' // No camera found on device
| 'error' // Generic error state
| 'off'; // Camera is off/stopped
export interface CameraStateListener {
onStateChange: (state: CameraState, message?: string) => void;
}
/**
* Interface for QR scanner service implementations
*/
@@ -44,6 +58,12 @@ export interface QRScannerService {
/** Add a listener for scan events */
addListener(listener: ScanListener): void;
/** Add a listener for camera state changes */
addCameraStateListener(listener: CameraStateListener): void;
/** Remove a camera state listener */
removeCameraStateListener(listener: CameraStateListener): void;
/** Clean up scanner resources */
cleanup(): Promise<void>;
}