diff --git a/package.json b/package.json index 919054f3..57cad6d1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "build:pywebview": "vite build --config vite.config.pywebview.mts", "build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js", "build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts", - "build:web": "vite build --config vite.config.web.mts", + "build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts", "electron:dev": "npm run build && electron .", "electron:start": "electron .", "clean:android": "adb uninstall app.timesafari.app || true", diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index 8b911a92..2643ccd1 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -72,43 +72,148 @@ export class WebPlatformService implements PlatformService { } /** - * Opens a file input dialog configured for camera capture. - * Creates a temporary file input element to access the device camera. + * Opens the device camera for photo capture on desktop browsers using getUserMedia. + * On mobile browsers, uses file input with capture attribute. + * Falls back to file input if getUserMedia is not available or fails. * * @returns Promise resolving to the captured image data - * @throws Error if image capture fails or no image is selected - * - * @remarks - * Uses the 'capture' attribute to prefer the device camera. - * Falls back to file selection if camera is not available. - * Processes the captured image to ensure consistent format. */ async takePicture(): Promise { - return new Promise((resolve, reject) => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.capture = "environment"; + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (file) { - try { - const blob = await this.processImageFile(file); - resolve({ - blob, - fileName: file.name || "photo.jpg", - }); - } catch (error) { - logger.error("Error processing camera image:", error); - reject(new Error("Failed to process camera image")); + // If on mobile, use file input with capture attribute (existing behavior) + if (isMobile || !hasGetUserMedia) { + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.capture = "environment"; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + try { + const blob = await this.processImageFile(file); + resolve({ + blob, + fileName: file.name || "photo.jpg", + }); + } catch (error) { + logger.error("Error processing camera image:", error); + reject(new Error("Failed to process camera image")); + } + } else { + reject(new Error("No image captured")); } - } else { - reject(new Error("No image captured")); + }; + + input.click(); + }); + } + + // Desktop: Use getUserMedia for webcam capture + return new Promise(async (resolve, reject) => { + let stream: MediaStream | null = null; + let video: HTMLVideoElement | null = null; + let captureButton: HTMLButtonElement | null = null; + let overlay: HTMLDivElement | null = null; + let cleanup = () => { + if (stream) { + stream.getTracks().forEach((track) => track.stop()); } + if (video && video.parentNode) video.parentNode.removeChild(video); + if (captureButton && captureButton.parentNode) captureButton.parentNode.removeChild(captureButton); + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); }; + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); + // Create overlay for video and button + overlay = document.createElement("div"); + overlay.style.position = "fixed"; + overlay.style.top = "0"; + overlay.style.left = "0"; + overlay.style.width = "100vw"; + overlay.style.height = "100vh"; + overlay.style.background = "rgba(0,0,0,0.8)"; + overlay.style.display = "flex"; + overlay.style.flexDirection = "column"; + overlay.style.justifyContent = "center"; + overlay.style.alignItems = "center"; + overlay.style.zIndex = "9999"; - input.click(); + video = document.createElement("video"); + video.autoplay = true; + video.playsInline = true; + video.style.maxWidth = "90vw"; + video.style.maxHeight = "70vh"; + video.srcObject = stream; + overlay.appendChild(video); + + captureButton = document.createElement("button"); + captureButton.textContent = "Capture Photo"; + captureButton.style.marginTop = "2rem"; + captureButton.style.padding = "1rem 2rem"; + captureButton.style.fontSize = "1.2rem"; + captureButton.style.background = "#2563eb"; + captureButton.style.color = "white"; + captureButton.style.border = "none"; + captureButton.style.borderRadius = "0.5rem"; + captureButton.style.cursor = "pointer"; + overlay.appendChild(captureButton); + + document.body.appendChild(overlay); + + captureButton.onclick = async () => { + try { + // Create a canvas to capture the frame + const canvas = document.createElement("canvas"); + canvas.width = video!.videoWidth; + canvas.height = video!.videoHeight; + const ctx = canvas.getContext("2d"); + ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { + cleanup(); + if (blob) { + resolve({ + blob, + fileName: `photo_${Date.now()}.jpg`, + }); + } else { + reject(new Error("Failed to capture image from webcam")); + } + }, "image/jpeg", 0.95); + } catch (err) { + cleanup(); + reject(err); + } + }; + } catch (error) { + cleanup(); + logger.error("Error accessing webcam:", error); + // Fallback to file input + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + try { + const blob = await this.processImageFile(file); + resolve({ + blob, + fileName: file.name || "photo.jpg", + }); + } catch (error) { + logger.error("Error processing fallback image:", error); + reject(new Error("Failed to process fallback image")); + } + } else { + reject(new Error("No image selected")); + } + }; + input.click(); + } }); } @@ -228,4 +333,14 @@ export class WebPlatformService implements PlatformService { // Web platform can handle deep links through URL parameters return Promise.resolve(); } + + /** + * Not supported in web platform. + * @param _fileName - Unused fileName parameter + * @param _content - Unused content parameter + * @throws Error indicating file system access is not available + */ + async writeAndShareFile(_fileName: string, _content: string): Promise { + throw new Error("File system access not available in web platform"); + } }