From cfc0730e75685c2459a554386fa4b30327f0fda8 Mon Sep 17 00:00:00 2001 From: Matt Raymer Date: Mon, 19 May 2025 04:40:18 -0400 Subject: [PATCH] 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. --- src/services/QRScanner/WebInlineQRScanner.ts | 119 ++++++++++-------- src/services/QRScanner/types.ts | 20 +++ src/views/ContactQRScanShowView.vue | 126 ++++++++++++------- 3 files changed, 165 insertions(+), 100 deletions(-) diff --git a/src/services/QRScanner/WebInlineQRScanner.ts b/src/services/QRScanner/WebInlineQRScanner.ts index e7560415..a038dc22 100644 --- a/src/services/QRScanner/WebInlineQRScanner.ts +++ b/src/services/QRScanner/WebInlineQRScanner.ts @@ -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 = 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 { 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 { 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; } } } diff --git a/src/services/QRScanner/types.ts b/src/services/QRScanner/types.ts index dda1a38a..9d21a69c 100644 --- a/src/services/QRScanner/types.ts +++ b/src/services/QRScanner/types.ts @@ -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; } diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 52439805..da7cc538 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -88,7 +88,7 @@ class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10" >
- {{ initializationStatus }} + {{ cameraStateMessage || 'Initializing camera...' }}

Error: {{ error }}

- - Ready to scan + + {{ cameraStateMessage || 'Ready to scan' }}

@@ -202,6 +208,7 @@ import { retrieveAccountMetadata } from "../libs/util"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; +import { CameraState } from "@/services/QRScanner/types"; interface QRScanResult { rawValue?: string; @@ -239,7 +246,8 @@ export default class ContactQRScanShow extends Vue { initializationStatus = "Initializing camera..."; useQRReader = __USE_QR_READER__; preferredCamera: "user" | "environment" = "environment"; - cameraStatus = "Initializing"; + cameraState: CameraState = 'off'; + cameraStateMessage?: string; ETHR_DID_PREFIX = ETHR_DID_PREFIX; @@ -303,19 +311,70 @@ export default class ContactQRScanShow extends Vue { try { this.error = null; this.isScanning = true; - this.isInitializing = true; - this.initializationStatus = "Initializing camera..."; this.lastScannedValue = ""; this.lastScanTime = 0; const scanner = QRScannerFactory.getInstance(); + // Add camera state listener + scanner.addCameraStateListener({ + onStateChange: (state, message) => { + this.cameraState = state; + this.cameraStateMessage = message; + + // Update UI based on camera state + switch (state) { + case 'in_use': + this.error = "Camera is in use by another application"; + this.isScanning = false; + this.$notify( + { + group: "alert", + type: "warning", + title: "Camera in Use", + text: "Please close other applications using the camera and try again", + }, + 5000, + ); + break; + case 'permission_denied': + this.error = "Camera permission denied"; + this.isScanning = false; + this.$notify( + { + group: "alert", + type: "warning", + title: "Camera Access Required", + text: "Please grant camera permission to scan QR codes", + }, + 5000, + ); + break; + case 'not_found': + this.error = "No camera found"; + this.isScanning = false; + this.$notify( + { + group: "alert", + type: "warning", + title: "No Camera", + text: "No camera was found on this device", + }, + 5000, + ); + break; + case 'error': + this.error = this.cameraStateMessage || "Camera error"; + this.isScanning = false; + break; + } + }, + }); + // Check if scanning is supported first if (!(await scanner.isSupported())) { - this.error = - "Camera access requires HTTPS. Please use a secure connection."; + this.error = "Camera access requires HTTPS. Please use a secure connection."; this.isScanning = false; - this.isInitializing = false; this.$notify( { group: "alert", @@ -328,40 +387,11 @@ export default class ContactQRScanShow extends Vue { return; } - // Check permissions first - if (!(await scanner.checkPermissions())) { - this.initializationStatus = "Requesting camera permission..."; - const granted = await scanner.requestPermissions(); - if (!granted) { - this.error = "Camera permission denied"; - this.isScanning = false; - this.isInitializing = false; - // Show notification for better visibility - this.$notify( - { - group: "alert", - type: "warning", - title: "Camera Access Required", - text: "Camera permission denied", - }, - 5000, - ); - return; - } - } - - // For native platforms, use the scanner service - scanner.addListener({ - onScan: this.onScanDetect, - onError: this.onScanError, - }); - // Start scanning await scanner.startScan(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); this.isScanning = false; - this.isInitializing = false; logger.error("Error starting scan:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, @@ -828,12 +858,12 @@ export default class ContactQRScanShow extends Vue { try { await promise; this.isInitializing = false; - this.cameraStatus = "Ready"; + this.cameraState = "ready"; } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); this.error = wrappedError.message; - this.cameraStatus = "Error"; + this.cameraState = "error"; this.isInitializing = false; logger.error("Error during QR scanner initialization:", { error: wrappedError.message, @@ -843,17 +873,17 @@ export default class ContactQRScanShow extends Vue { } onCameraOn(): void { - this.cameraStatus = "Active"; + this.cameraState = "active"; this.isInitializing = false; } onCameraOff(): void { - this.cameraStatus = "Off"; + this.cameraState = "off"; } onDetect(result: unknown): void { this.isScanning = true; - this.cameraStatus = "Detecting"; + this.cameraState = "detecting"; try { let rawValue: string | undefined; if ( @@ -874,7 +904,7 @@ export default class ContactQRScanShow extends Vue { this.handleError(error); } finally { this.isScanning = false; - this.cameraStatus = "Active"; + this.cameraState = "active"; } } @@ -897,12 +927,12 @@ export default class ContactQRScanShow extends Vue { const wrappedError = error instanceof Error ? error : new Error(String(error)); this.error = wrappedError.message; - this.cameraStatus = "Error"; + this.cameraState = "error"; } onError(error: Error): void { this.error = error.message; - this.cameraStatus = "Error"; + this.cameraState = "error"; logger.error("QR code scan error:", { error: error.message, stack: error.stack,