diff --git a/example-app/src/components/camera/old-methods/GetPhotoConfigurable.tsx b/example-app/src/components/camera/old-methods/GetPhotoConfigurable.tsx
index 70ec169..3665f08 100644
--- a/example-app/src/components/camera/old-methods/GetPhotoConfigurable.tsx
+++ b/example-app/src/components/camera/old-methods/GetPhotoConfigurable.tsx
@@ -72,6 +72,8 @@ class GetPhotoConfigurable extends React.Component<
this.props.onPhotoResult({
path: photo.path,
webPath: photo.webPath,
+ base64String: photo.base64String,
+ dataUrl: photo.dataUrl,
exif: photo.exif,
});
} catch (e) {
@@ -105,6 +107,8 @@ class GetPhotoConfigurable extends React.Component<
this.props.onPhotoResult({
path: photo.path,
webPath: photo.webPath,
+ base64String: photo.base64String,
+ dataUrl: photo.dataUrl,
exif: photo.exif,
});
} catch (e) {
diff --git a/example-app/src/components/camera/old-methods/types.ts b/example-app/src/components/camera/old-methods/types.ts
index cddd3b0..d77251d 100644
--- a/example-app/src/components/camera/old-methods/types.ts
+++ b/example-app/src/components/camera/old-methods/types.ts
@@ -30,5 +30,7 @@ export interface PickImagesConfig {
export interface PhotoResult {
path?: string;
webPath?: string;
+ base64String?: string;
+ dataUrl?: string;
exif?: any;
}
diff --git a/example-app/src/pages/MediaHistoryPage.tsx b/example-app/src/pages/MediaHistoryPage.tsx
index b9b9d5b..02e9bb6 100644
--- a/example-app/src/pages/MediaHistoryPage.tsx
+++ b/example-app/src/pages/MediaHistoryPage.tsx
@@ -20,6 +20,7 @@ import {
MediaHistoryItem,
} from "../services/MediaHistoryService";
import { FileViewer } from "@capacitor/file-viewer";
+import { Capacitor } from "@capacitor/core";
const MediaHistoryPage: React.FC = () => {
const [history, setHistory] = useState
([]);
@@ -35,6 +36,10 @@ const MediaHistoryPage: React.FC = () => {
const openFile = async (item: MediaHistoryItem): Promise => {
try {
+ if (Capacitor.getPlatform() === 'web') {
+ alert('Opening media from history is not available on Web/PWA.');
+ return;
+ }
const filePath = item.uri ?? item.path ?? '';
if (!filePath) {
alert('No file path available for this item');
diff --git a/example-app/src/pages/RecordVideoPage.tsx b/example-app/src/pages/RecordVideoPage.tsx
index dd0f4be..c1156d4 100644
--- a/example-app/src/pages/RecordVideoPage.tsx
+++ b/example-app/src/pages/RecordVideoPage.tsx
@@ -19,6 +19,7 @@ import { MediaHistoryService } from "../services/MediaHistoryService";
interface IRecordVideoPageState {
filePath: string | null;
metadata: MediaMetadata | string | null;
+ thumbnail: string | null;
}
class RecordVideoPage extends React.Component<{}, IRecordVideoPageState> {
@@ -27,6 +28,7 @@ class RecordVideoPage extends React.Component<{}, IRecordVideoPageState> {
this.state = {
filePath: null,
metadata: null,
+ thumbnail: null,
};
}
@@ -34,6 +36,7 @@ class RecordVideoPage extends React.Component<{}, IRecordVideoPageState> {
this.setState({
filePath: result.uri ?? result.webPath ?? '',
metadata: result.metadata ?? null,
+ thumbnail: result.thumbnail ?? null,
});
MediaHistoryService.addMedia({
@@ -65,6 +68,7 @@ class RecordVideoPage extends React.Component<{}, IRecordVideoPageState> {
this.setState({
filePath: null,
metadata: null,
+ thumbnail: null,
});
};
@@ -106,6 +110,7 @@ class RecordVideoPage extends React.Component<{}, IRecordVideoPageState> {
>
)}
diff --git a/example-app/src/pages/TakePicturePage.tsx b/example-app/src/pages/TakePicturePage.tsx
index 6411e55..cddaa21 100644
--- a/example-app/src/pages/TakePicturePage.tsx
+++ b/example-app/src/pages/TakePicturePage.tsx
@@ -58,14 +58,22 @@ class TakePicturePage extends React.Component<{}, ITakePicturePageState> {
handlePhotoResult = (result: {
path?: string;
webPath?: string;
+ base64String?: string;
+ dataUrl?: string;
exif?: any;
}): void => {
+ const filePath =
+ result.path ??
+ result.webPath ??
+ result.dataUrl ??
+ (result.base64String ? `data:image/jpeg;base64,${result.base64String}` : null);
+
this.setState({
- filePath: result.path ?? result.webPath ?? null,
+ filePath,
metadata: JSON.stringify(result.exif, null, 2),
});
- if (result.path || result.webPath) {
+ if (filePath) {
MediaHistoryService.addMedia({
mediaType: "photo",
method: "getPhoto",
diff --git a/src/definitions.ts b/src/definitions.ts
index 867de8f..ea5e461 100644
--- a/src/definitions.ts
+++ b/src/definitions.ts
@@ -214,7 +214,6 @@ export interface TakePhotoOptions {
/**
* Whether or not MediaResult should include its metadata.
* If an error occurs when obtaining the metadata, it will return empty.
- * Note: This option is only supported on Android and iOS.
* @default false
*
* @since 8.1.0
@@ -292,7 +291,6 @@ export interface ChooseFromGalleryOptions {
/**
* Whether or not MediaResult should include its metadata.
* If an error occurs when obtaining the metadata, it will return empty.
- * Note: This option is only supported on Android and iOS.
* @default false
*
* @since 8.1.0
@@ -401,7 +399,6 @@ export interface EditURIPhotoOptions {
/**
* Whether or not MediaResult should include its metadata.
* If an error occurs when obtaining the metadata, it will return empty.
- * Note: This option is only supported on Android and iOS.
* @default false
*
* @since 8.1.0
@@ -446,6 +443,7 @@ export interface MediaResult {
/**
* Returns the thumbnail of the media, base64 encoded.
* On Web, for `MediaType.Photo`, the full image is returned here, also base64 encoded.
+ * On Web, for `MediaType.Video`, a full-resolution JPEG frame captured from the video is returned, base64 encoded at 80% quality.
*
* @since 8.1.0
*/
@@ -480,8 +478,7 @@ export interface MediaResult {
export interface MediaMetadata {
/**
- * File size of the media, in bytes
- * Not available on Web.
+ * File size of the media, in bytes.
*
* @since 8.1.0
*/
@@ -489,7 +486,6 @@ export interface MediaMetadata {
/**
* Only applicable for `MediaType.Video` - the duration of the media, in seconds.
- * Not available on Web.
*
* @since 8.1.0
*/
@@ -499,7 +495,7 @@ export interface MediaMetadata {
* The format of the image, ex: jpeg, png, mp4.
*
* Web supports jpeg, png and gif, but the exact availability may vary depending on the browser.
- * gif is only supported for `chooseFromGallery`, and only if `webUseInput` option is set to `true`.
+ * gif is only supported for `chooseFromGallery` on Web.
*
* @since 8.1.0
*/
@@ -510,12 +506,12 @@ export interface MediaMetadata {
*
* @since 8.1.0
*/
- resolution: string;
+ resolution?: string;
/**
* The date and time the media was created, in ISO 8601 format.
* If creation date is not available (e.g. Android 7 and below), the last modified date is returned.
- * Not available on web.
+ * For Web, the last modified date is always returned.
*
* @since 8.1.0
*/
diff --git a/src/web.ts b/src/web.ts
index 57f7047..7b21f69 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -1,6 +1,6 @@
import { WebPlugin, CapacitorException } from '@capacitor/core';
-import { CameraSource, CameraDirection } from './definitions';
+import { CameraSource, CameraDirection, MediaType, MediaTypeSelection } from './definitions';
import type {
CameraPlugin,
GalleryImageOptions,
@@ -20,8 +20,15 @@ import type {
} from './definitions';
export class CameraWeb extends WebPlugin implements CameraPlugin {
- async takePhoto(_options: TakePhotoOptions): Promise {
- throw this.unimplemented('takePhoto is not implemented on Web.');
+ async takePhoto(options: TakePhotoOptions): Promise {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve, reject) => {
+ if (options.webUseInput) {
+ this.takePhotoCameraInputExperience(options, resolve, reject);
+ } else {
+ this.takePhotoCameraExperience(options, resolve, reject);
+ }
+ });
}
async recordVideo(_options: RecordVideoOptions): Promise {
@@ -32,8 +39,11 @@ export class CameraWeb extends WebPlugin implements CameraPlugin {
throw this.unimplemented('playVideo is not implemented on Web.');
}
- async chooseFromGallery(_options: ChooseFromGalleryOptions): Promise {
- throw this.unimplemented('chooseFromGallery is not implemented on web.');
+ async chooseFromGallery(options: ChooseFromGalleryOptions): Promise {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve, reject) => {
+ this.galleryInputExperience(options, resolve, reject);
+ });
}
async editPhoto(_options: EditPhotoOptions): Promise {
@@ -83,37 +93,13 @@ export class CameraWeb extends WebPlugin implements CameraPlugin {
}
private async cameraExperience(options: ImageOptions, resolve: any, reject: any) {
- if (customElements.get('pwa-camera-modal')) {
- const cameraModal: any = document.createElement('pwa-camera-modal');
- cameraModal.facingMode = options.direction === CameraDirection.Front ? 'user' : 'environment';
- document.body.appendChild(cameraModal);
- try {
- await cameraModal.componentOnReady();
- cameraModal.addEventListener('onPhoto', async (e: any) => {
- const photo = e.detail;
-
- if (photo === null) {
- reject(new CapacitorException('User cancelled photos app'));
- } else if (photo instanceof Error) {
- reject(photo);
- } else {
- resolve(await this._getCameraPhoto(photo, options));
- }
-
- cameraModal.dismiss();
- document.body.removeChild(cameraModal);
- });
-
- cameraModal.present();
- } catch (e) {
- this.fileInputExperience(options, resolve, reject);
- }
- } else {
- console.error(
- `Unable to load PWA Element 'pwa-camera-modal'. See the docs: https://capacitorjs.com/docs/web/pwa-elements.`,
- );
- this.fileInputExperience(options, resolve, reject);
- }
+ await this._setupPWACameraModal(
+ options.direction,
+ (photo) => this._getCameraPhoto(photo, options),
+ () => this.fileInputExperience(options, resolve, reject),
+ resolve,
+ reject,
+ );
}
private fileInputExperience(options: ImageOptions, resolve: any, reject: any) {
@@ -236,11 +222,11 @@ export class CameraWeb extends WebPlugin implements CameraPlugin {
private _getCameraPhoto(photo: Blob, options: ImageOptions) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
- const format = photo.type.split('/')[1];
+ const format = this._getFileFormat(photo);
if (options.resultType === 'uri') {
resolve({
webPath: URL.createObjectURL(photo),
- format: format,
+ format,
saved: false,
});
} else {
@@ -250,13 +236,13 @@ export class CameraWeb extends WebPlugin implements CameraPlugin {
if (options.resultType === 'dataUrl') {
resolve({
dataUrl: r,
- format: format,
+ format,
saved: false,
});
} else {
resolve({
base64String: r.split(',')[1],
- format: format,
+ format,
saved: false,
});
}
@@ -268,6 +254,305 @@ export class CameraWeb extends WebPlugin implements CameraPlugin {
});
}
+ private async takePhotoCameraExperience(options: TakePhotoOptions, resolve: any, reject: any) {
+ await this._setupPWACameraModal(
+ options.cameraDirection,
+ (photo) => this._buildPhotoMediaResult(photo, options.includeMetadata ?? false),
+ () => this.takePhotoCameraInputExperience(options, resolve, reject),
+ resolve,
+ reject,
+ );
+ }
+
+ private takePhotoCameraInputExperience(options: TakePhotoOptions, resolve: any, reject: any) {
+ const input = this._createFileInput('_capacitor-camera-input-takephoto');
+
+ const cleanup = () => {
+ input.parentNode?.removeChild(input);
+ };
+
+ input.onchange = async (_e: any) => {
+ if (!this._validateFileInput(input, reject, cleanup)) {
+ return;
+ }
+
+ const file = input.files![0];
+ resolve(await this._buildPhotoMediaResult(file, options.includeMetadata ?? false));
+ cleanup();
+ };
+
+ input.oncancel = () => {
+ reject(new CapacitorException('User cancelled photos app'));
+ cleanup();
+ };
+
+ input.accept = 'image/*';
+ if (options.cameraDirection === CameraDirection.Front) {
+ (input as any).capture = 'user';
+ } else {
+ // CameraDirection.Rear
+ (input as any).capture = 'environment';
+ }
+
+ input.click();
+ }
+
+ private galleryInputExperience(options: ChooseFromGalleryOptions, resolve: any, reject: any) {
+ const input = this._createFileInput('_capacitor-camera-input-gallery');
+ input.multiple = options.allowMultipleSelection ?? false;
+
+ const cleanup = () => {
+ input.parentNode?.removeChild(input);
+ };
+
+ input.onchange = async (_e: any) => {
+ if (!this._validateFileInput(input, reject, cleanup)) {
+ return;
+ }
+
+ const results: MediaResult[] = [];
+
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of
+ for (let i = 0; i < input.files!.length; i++) {
+ const file = input.files![i];
+
+ if (file.type.startsWith('image/')) {
+ results.push(await this._buildPhotoMediaResult(file, options.includeMetadata ?? false));
+ } else if (file.type.startsWith('video/')) {
+ const format = this._getFileFormat(file);
+ let thumbnail: string | undefined;
+ let resolution: string | undefined;
+ let duration: number | undefined;
+
+ try {
+ const videoInfo = await this._getVideoMetadata(file);
+ thumbnail = videoInfo.thumbnail;
+
+ if (options.includeMetadata) {
+ resolution = videoInfo.resolution;
+ duration = videoInfo.duration;
+ }
+ } catch (e) {
+ console.warn('Failed to get video metadata:', e);
+ }
+
+ const result: MediaResult = {
+ type: MediaType.Video,
+ thumbnail,
+ webPath: URL.createObjectURL(file),
+ saved: false,
+ };
+
+ if (options.includeMetadata) {
+ result.metadata = {
+ format,
+ resolution,
+ size: file.size,
+ creationDate: new Date(file.lastModified).toISOString(),
+ duration,
+ };
+ }
+
+ results.push(result);
+ }
+ }
+ resolve({ results });
+ cleanup();
+ };
+
+ input.oncancel = () => {
+ reject(new CapacitorException('User cancelled photos app'));
+ cleanup();
+ };
+
+ // Set accept attribute based on mediaType
+ const mediaType = options.mediaType ?? MediaTypeSelection.Photo;
+ if (mediaType === MediaTypeSelection.Photo) {
+ input.accept = 'image/*';
+ } else if (mediaType === MediaTypeSelection.Video) {
+ input.accept = 'video/*';
+ } else {
+ // MediaTypeSelection.All
+ input.accept = 'image/*,video/*';
+ }
+
+ input.click();
+ }
+
+ private _getFileFormat(file: File | Blob): string {
+ if (file.type === 'image/png') {
+ return 'png';
+ } else if (file.type === 'image/gif') {
+ return 'gif';
+ } else if (file.type.startsWith('video/')) {
+ return file.type.split('/')[1];
+ } else if (file.type.startsWith('image/')) {
+ return 'jpeg';
+ }
+ return file.type.split('/')[1] || 'jpeg';
+ }
+
+ private async _buildPhotoMediaResult(file: File | Blob, includeMetadata: boolean): Promise {
+ const format = this._getFileFormat(file);
+ const thumbnail = await this._getBase64FromFile(file);
+
+ const result: MediaResult = {
+ type: MediaType.Photo,
+ thumbnail,
+ webPath: URL.createObjectURL(file),
+ saved: false,
+ };
+
+ if (includeMetadata) {
+ const resolution = await this._getImageResolution(file);
+ result.metadata = {
+ format,
+ resolution,
+ size: file.size,
+ creationDate: 'lastModified' in file ? new Date(file.lastModified).toISOString() : new Date().toISOString(),
+ };
+ }
+
+ return result;
+ }
+
+ private _validateFileInput(input: HTMLInputElement, reject: any, cleanup: () => void): boolean {
+ if (!input.files || input.files.length === 0) {
+ const message = input.multiple ? 'No files selected' : 'No file selected';
+ reject(new CapacitorException(message));
+ cleanup();
+ return false;
+ }
+ return true;
+ }
+
+ private async _setupPWACameraModal(
+ cameraDirection: CameraDirection | undefined,
+ onPhotoCallback: (photo: Blob) => Promise,
+ fallbackCallback: () => void,
+ resolve: any,
+ reject: any,
+ ): Promise {
+ if (customElements.get('pwa-camera-modal')) {
+ const cameraModal: any = document.createElement('pwa-camera-modal');
+ cameraModal.facingMode = cameraDirection === CameraDirection.Front ? 'user' : 'environment';
+ document.body.appendChild(cameraModal);
+ try {
+ await cameraModal.componentOnReady();
+ cameraModal.addEventListener('onPhoto', async (e: any) => {
+ const photo = e.detail;
+
+ if (photo === null) {
+ reject(new CapacitorException('User cancelled photos app'));
+ } else if (photo instanceof Error) {
+ reject(photo);
+ } else {
+ resolve(await onPhotoCallback(photo));
+ }
+
+ cameraModal.dismiss();
+ document.body.removeChild(cameraModal);
+ });
+
+ cameraModal.present();
+ } catch (e) {
+ fallbackCallback();
+ }
+ } else {
+ console.error(
+ `Unable to load PWA Element 'pwa-camera-modal'. See the docs: https://capacitorjs.com/docs/web/pwa-elements.`,
+ );
+ fallbackCallback();
+ }
+ }
+
+ private _createFileInput(id: string): HTMLInputElement {
+ let input = document.querySelector(`#${id}`) as HTMLInputElement;
+
+ if (!input) {
+ input = document.createElement('input') as HTMLInputElement;
+ input.id = id;
+ input.type = 'file';
+ input.hidden = true;
+ document.body.appendChild(input);
+ }
+
+ return input;
+ }
+
+ private async _getImageResolution(image: Blob | File): Promise {
+ try {
+ const bitmap = await createImageBitmap(image);
+ const resolution = `${bitmap.width}x${bitmap.height}`;
+ bitmap.close();
+ return resolution;
+ } catch (e) {
+ console.warn('Failed to get image resolution:', e);
+ return undefined;
+ }
+ }
+
+ private _getBase64FromFile(file: File | Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const dataUrl = reader.result as string;
+ const base64 = dataUrl.split(',')[1];
+ resolve(base64);
+ };
+ reader.onerror = (e) => {
+ reject(e);
+ };
+ reader.readAsDataURL(file);
+ });
+ }
+
+ private _getVideoMetadata(videoFile: File): Promise<{ resolution?: string; duration?: number; thumbnail?: string }> {
+ return new Promise((resolve) => {
+ const video = document.createElement('video');
+ video.preload = 'metadata';
+ video.muted = true;
+
+ video.onloadedmetadata = () => {
+ // Seek to 1 second or 10% of duration to capture thumbnail
+ const seekTime = Math.min(1, video.duration * 0.1);
+ video.currentTime = seekTime;
+ };
+
+ video.onseeked = () => {
+ const result: { resolution?: string; duration?: number; thumbnail?: string } = {
+ resolution: `${video.videoWidth}x${video.videoHeight}`,
+ duration: video.duration,
+ };
+
+ try {
+ const canvas = document.createElement('canvas');
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ const ctx = canvas.getContext('2d');
+
+ if (ctx) {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ result.thumbnail = canvas.toDataURL('image/jpeg', 0.8).split(',')[1];
+ }
+ } catch (e) {
+ console.warn('Failed to generate video thumbnail:', e);
+ }
+
+ URL.revokeObjectURL(video.src);
+ resolve(result);
+ };
+
+ video.onerror = () => {
+ // Clean up and return defaults
+ URL.revokeObjectURL(video.src);
+ resolve({});
+ };
+
+ video.src = URL.createObjectURL(videoFile);
+ });
+ }
+
async checkPermissions(): Promise {
if (typeof navigator === 'undefined' || !navigator.permissions) {
throw this.unavailable('Permissions API not available in this browser');