/** * Mobile Camera Utilities * Provides camera access, photo capture, and image processing for mobile devices */ export interface CameraConfig { facingMode?: 'user' | 'environment'; width?: number; height?: number; aspectRatio?: number; } export interface CapturedImage { blob: Blob; dataUrl: string; width: number; height: number; } export class CameraService { private stream: MediaStream | null = null; private videoElement: HTMLVideoElement | null = null; async checkCameraAvailability(): Promise { try { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.some((device) => device.kind === 'videoinput'); } catch { return false; } } async requestPermission(): Promise { try { const result = await navigator.permissions.query({ name: 'camera' as PermissionName }); return result.state; } catch { // Fallback: try to access camera to trigger permission prompt try { const stream = await navigator.mediaDevices.getUserMedia({ video: true }); stream.getTracks().forEach((track) => track.stop()); return 'granted'; } catch { return 'denied'; } } } async startCamera(videoElement: HTMLVideoElement, config: CameraConfig = {}): Promise { const { facingMode = 'environment', width = 1280, height = 720, aspectRatio, } = config; const constraints: MediaStreamConstraints = { video: { facingMode, width: { ideal: width }, height: { ideal: height }, ...(aspectRatio && { aspectRatio }), }, }; try { this.stream = await navigator.mediaDevices.getUserMedia(constraints); videoElement.srcObject = this.stream; this.videoElement = videoElement; await videoElement.play(); } catch (error) { throw new Error(`Failed to start camera: ${(error as Error).message}`); } } async capturePhoto(quality = 0.9): Promise { if (!this.videoElement || !this.stream) { throw new Error('Camera not started'); } const canvas = document.createElement('canvas'); const video = this.videoElement; canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } ctx.drawImage(video, 0, 0, canvas.width, canvas.height); return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Failed to capture photo')); return; } const dataUrl = canvas.toDataURL('image/jpeg', quality); resolve({ blob, dataUrl, width: canvas.width, height: canvas.height, }); }, 'image/jpeg', quality ); }); } async switchCamera(): Promise { if (!this.videoElement) { throw new Error('Camera not started'); } const currentTrack = this.stream?.getVideoTracks()[0]; const currentFacingMode = currentTrack?.getSettings().facingMode; const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user'; this.stopCamera(); await this.startCamera(this.videoElement, { facingMode: newFacingMode }); } stopCamera(): void { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); this.stream = null; } if (this.videoElement) { this.videoElement.srcObject = null; this.videoElement = null; } } isActive(): boolean { return this.stream !== null && this.stream.active; } } // Image processing utilities export async function cropImage( image: CapturedImage, x: number, y: number, width: number, height: number ): Promise { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } const img = await loadImage(image.dataUrl); ctx.drawImage(img, x, y, width, height, 0, 0, width, height); return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Failed to crop image')); return; } resolve({ blob, dataUrl: canvas.toDataURL('image/jpeg', 0.9), width, height, }); }, 'image/jpeg', 0.9 ); }); } export async function resizeImage( image: CapturedImage, maxWidth: number, maxHeight: number ): Promise { const img = await loadImage(image.dataUrl); let { width, height } = img; const ratio = Math.min(maxWidth / width, maxHeight / height); if (ratio < 1) { width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } ctx.drawImage(img, 0, 0, width, height); return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Failed to resize image')); return; } resolve({ blob, dataUrl: canvas.toDataURL('image/jpeg', 0.9), width, height, }); }, 'image/jpeg', 0.9 ); }); } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } // Singleton instance let cameraInstance: CameraService | null = null; export function getCamera(): CameraService { if (!cameraInstance) { cameraInstance = new CameraService(); } return cameraInstance; } export default CameraService;