forked from trent_larson/crowd-funder-for-time-pwa
feat(web): enable desktop webcam capture in WebPlatformService
- Updated WebPlatformService.takePicture() to use getUserMedia for webcam capture on desktop browsers, providing a live video preview and capture button in an overlay. - Retained file input with capture attribute for mobile browsers and as a fallback if webcam access fails. - Ensured interface and factory pattern compatibility; no changes required in PhotoDialog.vue or PlatformServiceFactory. - Added a stub for writeAndShareFile to satisfy the PlatformService interface on web.
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
"build:pywebview": "vite build --config vite.config.pywebview.mts",
|
"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: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: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:dev": "npm run build && electron .",
|
||||||
"electron:start": "electron .",
|
"electron:start": "electron .",
|
||||||
"clean:android": "adb uninstall app.timesafari.app || true",
|
"clean:android": "adb uninstall app.timesafari.app || true",
|
||||||
|
|||||||
@@ -72,43 +72,148 @@ export class WebPlatformService implements PlatformService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a file input dialog configured for camera capture.
|
* Opens the device camera for photo capture on desktop browsers using getUserMedia.
|
||||||
* Creates a temporary file input element to access the device camera.
|
* 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
|
* @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<ImageResult> {
|
async takePicture(): Promise<ImageResult> {
|
||||||
return new Promise((resolve, reject) => {
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
const input = document.createElement("input");
|
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
input.type = "file";
|
|
||||||
input.accept = "image/*";
|
|
||||||
input.capture = "environment";
|
|
||||||
|
|
||||||
input.onchange = async (e) => {
|
// If on mobile, use file input with capture attribute (existing behavior)
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
if (isMobile || !hasGetUserMedia) {
|
||||||
if (file) {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
const input = document.createElement("input");
|
||||||
const blob = await this.processImageFile(file);
|
input.type = "file";
|
||||||
resolve({
|
input.accept = "image/*";
|
||||||
blob,
|
input.capture = "environment";
|
||||||
fileName: file.name || "photo.jpg",
|
|
||||||
});
|
input.onchange = async (e) => {
|
||||||
} catch (error) {
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
logger.error("Error processing camera image:", error);
|
if (file) {
|
||||||
reject(new Error("Failed to process camera image"));
|
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();
|
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";
|
||||||
|
|
||||||
|
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
|
// Web platform can handle deep links through URL parameters
|
||||||
return Promise.resolve();
|
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<void> {
|
||||||
|
throw new Error("File system access not available in web platform");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user