Browse Source

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.
sql-wa-sqlite
Matt Raymer 3 weeks ago
parent
commit
cfc0730e75
  1. 115
      src/services/QRScanner/WebInlineQRScanner.ts
  2. 20
      src/services/QRScanner/types.ts
  3. 126
      src/views/ContactQRScanShowView.vue

115
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 { logger } from "@/utils/logger";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import jsQR from "jsqr"; 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 TARGET_FPS = 15; // Target 15 FPS for scanning
private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames
private lastFrameTime = 0; private lastFrameTime = 0;
private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = 'off';
private currentStateMessage?: string;
constructor(private options?: QRScannerOptions) { constructor(private options?: QRScannerOptions) {
// Generate a short random ID for this scanner instance // 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> { async checkPermissions(): Promise<boolean> {
try { try {
this.updateCameraState('initializing', 'Checking camera permissions...');
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`, `[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
); );
@ -55,7 +85,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Permission state:`, `[WebInlineQRScanner:${this.id}] Permission state:`,
permissions.state, permissions.state,
); );
return permissions.state === "granted"; const granted = permissions.state === "granted";
this.updateCameraState(granted ? 'ready' : 'permission_denied');
return granted;
} catch (error) { } catch (error) {
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Error checking camera permissions:`, `[WebInlineQRScanner:${this.id}] Error checking camera permissions:`,
@ -64,12 +96,14 @@ export class WebInlineQRScanner implements QRScannerService {
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
}, },
); );
this.updateCameraState('error', 'Error checking camera permissions');
return false; return false;
} }
} }
async requestPermissions(): Promise<boolean> { async requestPermissions(): Promise<boolean> {
try { try {
this.updateCameraState('initializing', 'Requesting camera permissions...');
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
); );
@ -107,9 +141,7 @@ export class WebInlineQRScanner implements QRScannerService {
}, },
}); });
logger.error( this.updateCameraState('ready', 'Camera permissions granted');
`[WebInlineQRScanner:${this.id}] Camera stream obtained successfully`,
);
// Stop the test stream immediately // Stop the test stream immediately
stream.getTracks().forEach((track) => { stream.getTracks().forEach((track) => {
@ -122,36 +154,20 @@ export class WebInlineQRScanner implements QRScannerService {
}); });
return true; return true;
} catch (error) { } catch (error) {
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
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 // Update state based on error type
if ( if (wrappedError.name === "NotFoundError" || wrappedError.name === "DevicesNotFoundError") {
wrappedError.name === "NotFoundError" || this.updateCameraState('not_found', 'No camera found on this device');
wrappedError.name === "DevicesNotFoundError"
) {
throw new Error("No camera found on this device"); throw new Error("No camera found on this device");
} else if ( } else if (wrappedError.name === "NotAllowedError" || wrappedError.name === "PermissionDeniedError") {
wrappedError.name === "NotAllowedError" || this.updateCameraState('permission_denied', 'Camera access denied');
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( this.updateCameraState('in_use', 'Camera is in use by another application');
"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"); throw new Error("Camera is in use by another application");
} else { } else {
this.updateCameraState('error', wrappedError.message);
throw new Error(`Camera error: ${wrappedError.message}`); throw new Error(`Camera error: ${wrappedError.message}`);
} }
} }
@ -390,6 +406,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.isScanning = true; this.isScanning = true;
this.scanAttempts = 0; this.scanAttempts = 0;
this.lastScanTime = Date.now(); this.lastScanTime = Date.now();
this.updateCameraState('initializing', 'Starting camera...');
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`);
// Get camera stream // 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:`, { logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
tracks: this.stream.getTracks().map((t) => ({ tracks: this.stream.getTracks().map((t) => ({
kind: t.kind, kind: t.kind,
@ -429,13 +448,15 @@ export class WebInlineQRScanner implements QRScannerService {
this.scanQRCode(); this.scanQRCode();
} catch (error) { } catch (error) {
this.isScanning = false; this.isScanning = false;
const wrappedError = const wrappedError = error instanceof Error ? error : new Error(String(error));
error instanceof Error ? error : new Error(String(error));
logger.error(`[WebInlineQRScanner:${this.id}] Error starting scan:`, { // Update state based on error type
error: wrappedError.message, if (wrappedError.name === "NotReadableError" || wrappedError.name === "TrackStartError") {
stack: wrappedError.stack, this.updateCameraState('in_use', 'Camera is in use by another application');
name: wrappedError.name, } else {
}); this.updateCameraState('error', wrappedError.message);
}
if (this.scanListener?.onError) { if (this.scanListener?.onError) {
this.scanListener.onError(wrappedError); this.scanListener.onError(wrappedError);
} }
@ -492,14 +513,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, `[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
); );
} catch (error) { } catch (error) {
const wrappedError = logger.error(`[WebInlineQRScanner:${this.id}] Error stopping scan:`, error);
error instanceof Error ? error : new Error(String(error)); this.updateCameraState('error', 'Error stopping camera');
logger.error(`[WebInlineQRScanner:${this.id}] Error stopping scan:`, { throw error;
error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name,
});
throw wrappedError;
} finally { } finally {
this.isScanning = false; this.isScanning = false;
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
@ -541,10 +557,9 @@ export class WebInlineQRScanner implements QRScannerService {
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
); );
} catch (error) { } catch (error) {
logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, { logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, error);
error: error instanceof Error ? error.message : String(error), this.updateCameraState('error', 'Error during cleanup');
stack: error instanceof Error ? error.stack : undefined, throw error;
});
} }
} }
} }

20
src/services/QRScanner/types.ts

@ -22,6 +22,20 @@ export interface QRScannerOptions {
playSound?: boolean; 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 * Interface for QR scanner service implementations
*/ */
@ -44,6 +58,12 @@ export interface QRScannerService {
/** Add a listener for scan events */ /** Add a listener for scan events */
addListener(listener: ScanListener): void; 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 */ /** Clean up scanner resources */
cleanup(): Promise<void>; cleanup(): Promise<void>;
} }

126
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" class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10"
> >
<div <div
v-if="isInitializing" v-if="cameraState === 'initializing'"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
> >
<svg <svg
@ -112,10 +112,10 @@
3.042 1.135 5.824 3 7.938l3-2.647z" 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
<span>{{ initializationStatus }}</span> <span>{{ cameraStateMessage || 'Initializing camera...' }}</span>
</div> </div>
<p <p
v-else-if="isScanning" v-else-if="cameraState === 'active'"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
> >
<span <span
@ -125,8 +125,14 @@
</p> </p>
<p v-else-if="error" class="text-red-400">Error: {{ error }}</p> <p v-else-if="error" class="text-red-400">Error: {{ error }}</p>
<p v-else class="flex items-center justify-center space-x-2"> <p v-else class="flex items-center justify-center space-x-2">
<span class="inline-block w-2 h-2 bg-blue-500 rounded-full"></span> <span :class="{
<span>Ready to scan</span> 'inline-block w-2 h-2 rounded-full': true,
'bg-green-500': cameraState === 'ready',
'bg-yellow-500': cameraState === 'in_use',
'bg-red-500': cameraState === 'error' || cameraState === 'permission_denied' || cameraState === 'not_found',
'bg-blue-500': cameraState === 'off'
}"></span>
<span>{{ cameraStateMessage || 'Ready to scan' }}</span>
</p> </p>
</div> </div>
@ -202,6 +208,7 @@ import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@ -239,7 +246,8 @@ export default class ContactQRScanShow extends Vue {
initializationStatus = "Initializing camera..."; initializationStatus = "Initializing camera...";
useQRReader = __USE_QR_READER__; useQRReader = __USE_QR_READER__;
preferredCamera: "user" | "environment" = "environment"; preferredCamera: "user" | "environment" = "environment";
cameraStatus = "Initializing"; cameraState: CameraState = 'off';
cameraStateMessage?: string;
ETHR_DID_PREFIX = ETHR_DID_PREFIX; ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@ -303,19 +311,70 @@ export default class ContactQRScanShow extends Vue {
try { try {
this.error = null; this.error = null;
this.isScanning = true; this.isScanning = true;
this.isInitializing = true;
this.initializationStatus = "Initializing camera...";
this.lastScannedValue = ""; this.lastScannedValue = "";
this.lastScanTime = 0; this.lastScanTime = 0;
const scanner = QRScannerFactory.getInstance(); 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 // Check if scanning is supported first
if (!(await scanner.isSupported())) { if (!(await scanner.isSupported())) {
this.error = this.error = "Camera access requires HTTPS. Please use a secure connection.";
"Camera access requires HTTPS. Please use a secure connection.";
this.isScanning = false; this.isScanning = false;
this.isInitializing = false;
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -328,40 +387,11 @@ export default class ContactQRScanShow extends Vue {
return; 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 // Start scanning
await scanner.startScan(); await scanner.startScan();
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false; this.isScanning = false;
this.isInitializing = false;
logger.error("Error starting scan:", { logger.error("Error starting scan:", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
@ -828,12 +858,12 @@ export default class ContactQRScanShow extends Vue {
try { try {
await promise; await promise;
this.isInitializing = false; this.isInitializing = false;
this.cameraStatus = "Ready"; this.cameraState = "ready";
} catch (error) { } catch (error) {
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
this.cameraStatus = "Error"; this.cameraState = "error";
this.isInitializing = false; this.isInitializing = false;
logger.error("Error during QR scanner initialization:", { logger.error("Error during QR scanner initialization:", {
error: wrappedError.message, error: wrappedError.message,
@ -843,17 +873,17 @@ export default class ContactQRScanShow extends Vue {
} }
onCameraOn(): void { onCameraOn(): void {
this.cameraStatus = "Active"; this.cameraState = "active";
this.isInitializing = false; this.isInitializing = false;
} }
onCameraOff(): void { onCameraOff(): void {
this.cameraStatus = "Off"; this.cameraState = "off";
} }
onDetect(result: unknown): void { onDetect(result: unknown): void {
this.isScanning = true; this.isScanning = true;
this.cameraStatus = "Detecting"; this.cameraState = "detecting";
try { try {
let rawValue: string | undefined; let rawValue: string | undefined;
if ( if (
@ -874,7 +904,7 @@ export default class ContactQRScanShow extends Vue {
this.handleError(error); this.handleError(error);
} finally { } finally {
this.isScanning = false; this.isScanning = false;
this.cameraStatus = "Active"; this.cameraState = "active";
} }
} }
@ -897,12 +927,12 @@ export default class ContactQRScanShow extends Vue {
const wrappedError = const wrappedError =
error instanceof Error ? error : new Error(String(error)); error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message; this.error = wrappedError.message;
this.cameraStatus = "Error"; this.cameraState = "error";
} }
onError(error: Error): void { onError(error: Error): void {
this.error = error.message; this.error = error.message;
this.cameraStatus = "Error"; this.cameraState = "error";
logger.error("QR code scan error:", { logger.error("QR code scan error:", {
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,

Loading…
Cancel
Save