From 6c8c7b2da8a982df95c9bde9810b518626067507 Mon Sep 17 00:00:00 2001
From: Paul Li <82799722+wmdev0808@users.noreply.github.com>
Date: Fri, 24 Dec 2021 22:06:59 +0800
Subject: [PATCH] feat: camera widget (#8069)
* feat: Camera Widget
-- Scaffold the basic structure of the widget
* feat: Camera Widget
-- Prototype a feature, taking picture
* feat: Camera Widget
-- Add types for MediaRecorder
-- Define media capture status and action types
-- Prototype basic video recording, playing features
* feat: Camera Widget
-- Implement video player
-- Add timer for recording and playing video
-- Add permission and error handling logic
-- Add device selectors
* feat: Camera Widget
-- Place control buttons above device inputs layer
-- Make the widget fully responsive
* feat: Camera Widget
-- Change the color of caret-down icon to white
-- Remove overlaying of web cam and video player
-- Add some padding for device inputs
* feat: Camera Widget
-- Add black background to the container of the widget
* feat: Camera Widget
-- Change the widget icon
* feat: Camera Widget
-- Implement the mute feature of a mic or a camera
* feat: Camera Widget
-- Check media device permissions before getting started
* feat: Camera Widget
-- Add a fullscreen control
* feat: Camera Widget
-- Set error text color to white
-- Change the layout of control panel
* feat: Camera Widget
-- Apply layout change for control panel according to app layout change
* feat: Camera Widget
-- Add a new derived property, videoURL
* feat: Switch Group Widget
-- Adopt theme changes
* feat: Camera Widget
-- Make background grey in case of both error and disabled status
* feat: Camera Widget
-- Update npm dependencies
* feat: Camera Widget
-- Fix on #8788, using muted property
* feat: Camera Widget
-- Show off the microphone setting icon only if the current mode is video
-- Set isMirrored property to true by default
* feat: Camera Widget
-- Add photo viewer
* feat: Camera Widget
-- Add onImageCapture, onRecordingStart, onRecordingStop actions instead of onMediaCapture
* feat: Camera Widget
-- Expose meta properties for the widget
* feat: Camera Widget
-- Fix on responsiveness issue
* feat: Camera Widget
-- Add type definitions for MediaStream recording
* feat: Camera Widget
-- Hide isMirroed property for video mode
* feat: Camera Widget
-- Wrap all the controls with TooltipComponent
* feat: Camera Widget
-- Implement enter, exit full screen feature
* feat: Camera Widget
-- Add a widget icon for entity explorer
* feat: Camera Widget
-- Fix on the typo for the label of onRecordingStop property
* feat: Camera Widget
-- Enable/disable media tracks
* feat: Camera Widget
-- Set the video's height to 100% in fullscreen mode
* feat: Camera Widget
-- Add overlayers on Webcam
* feat: Camera Widget
-- Set position to relative on fullscreen wrapper div
-- Set the photo viewer's height to 100%
* feat: Camera Widget
-- Add image, mediaCaptureStatus, timer meta properties to keep UI states when the widget is dragged
* feat: Camera Widget
-- Refactor code base, eliminating commented code blocks
* feat: Camera Widget
-- Revert all the changes needed for keeping status when the widget is dragged
-- Set mirroed property to false for video mode
---
app/client/package.json | 3 +
app/client/src/assets/icons/widget/camera.svg | 3 +
.../icons/widget/camera/camera-muted.svg | 4 +
.../icons/widget/camera/camera-offline.svg | 3 +
.../src/assets/icons/widget/camera/camera.svg | 3 +
.../icons/widget/camera/exit-fullscreen.svg | 3 +
.../assets/icons/widget/camera/fullscreen.svg | 3 +
.../icons/widget/camera/microphone-muted.svg | 3 +
.../assets/icons/widget/camera/microphone.svg | 3 +
.../ActionConstants.tsx | 3 +
app/client/src/globalStyles/tooltip.ts | 8 +-
app/client/src/icons/WidgetIcons.tsx | 11 +-
app/client/src/utils/AppsmithUtils.tsx | 31 +
app/client/src/utils/WidgetRegistry.tsx | 5 +
.../utils/autocomplete/EntityDefinitions.ts | 11 +
.../widgets/CameraWidget/component/index.tsx | 1207 +++++++++++++++++
.../src/widgets/CameraWidget/constants.ts | 47 +
app/client/src/widgets/CameraWidget/icon.svg | 3 +
app/client/src/widgets/CameraWidget/index.ts | 30 +
.../src/widgets/CameraWidget/widget/index.tsx | 275 ++++
app/client/yarn.lock | 22 +
21 files changed, 1675 insertions(+), 6 deletions(-)
create mode 100755 app/client/src/assets/icons/widget/camera.svg
create mode 100755 app/client/src/assets/icons/widget/camera/camera-muted.svg
create mode 100755 app/client/src/assets/icons/widget/camera/camera-offline.svg
create mode 100755 app/client/src/assets/icons/widget/camera/camera.svg
create mode 100644 app/client/src/assets/icons/widget/camera/exit-fullscreen.svg
create mode 100755 app/client/src/assets/icons/widget/camera/fullscreen.svg
create mode 100755 app/client/src/assets/icons/widget/camera/microphone-muted.svg
create mode 100755 app/client/src/assets/icons/widget/camera/microphone.svg
create mode 100644 app/client/src/widgets/CameraWidget/component/index.tsx
create mode 100644 app/client/src/widgets/CameraWidget/constants.ts
create mode 100755 app/client/src/widgets/CameraWidget/icon.svg
create mode 100644 app/client/src/widgets/CameraWidget/index.ts
create mode 100644 app/client/src/widgets/CameraWidget/widget/index.tsx
diff --git a/app/client/package.json b/app/client/package.json
index c0fb960009..8c20f75bab 100644
--- a/app/client/package.json
+++ b/app/client/package.json
@@ -102,6 +102,7 @@
"react-dnd-touch-backend": "^9.4.0",
"react-documents": "^1.0.4",
"react-dom": "^16.7.0",
+ "react-full-screen": "^1.1.0",
"react-google-maps": "^9.4.5",
"react-google-recaptcha": "^2.1.0",
"react-helmet": "^5.2.1",
@@ -128,6 +129,7 @@
"react-transition-group": "^4.3.0",
"react-use-gesture": "^7.0.4",
"react-virtuoso": "^1.9.0",
+ "react-webcam": "^6.0.0",
"react-window": "^1.8.6",
"react-zoom-pan-pinch": "^1.6.1",
"redux": "^4.0.1",
@@ -202,6 +204,7 @@
"@types/chance": "^1.0.7",
"@types/codemirror": "^0.0.96",
"@types/deep-diff": "^1.0.0",
+ "@types/dom-mediacapture-record": "^1.0.11",
"@types/downloadjs": "^1.4.2",
"@types/draft-js": "^0.11.1",
"@types/emoji-mart": "^3.0.4",
diff --git a/app/client/src/assets/icons/widget/camera.svg b/app/client/src/assets/icons/widget/camera.svg
new file mode 100755
index 0000000000..4e936cf6a9
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/client/src/assets/icons/widget/camera/camera-muted.svg b/app/client/src/assets/icons/widget/camera/camera-muted.svg
new file mode 100755
index 0000000000..8870a8631e
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/camera-muted.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/client/src/assets/icons/widget/camera/camera-offline.svg b/app/client/src/assets/icons/widget/camera/camera-offline.svg
new file mode 100755
index 0000000000..0190cd1a1b
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/camera-offline.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/client/src/assets/icons/widget/camera/camera.svg b/app/client/src/assets/icons/widget/camera/camera.svg
new file mode 100755
index 0000000000..08cf224f98
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/camera.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/client/src/assets/icons/widget/camera/exit-fullscreen.svg b/app/client/src/assets/icons/widget/camera/exit-fullscreen.svg
new file mode 100644
index 0000000000..74aec64941
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/exit-fullscreen.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/client/src/assets/icons/widget/camera/fullscreen.svg b/app/client/src/assets/icons/widget/camera/fullscreen.svg
new file mode 100755
index 0000000000..129c7504a7
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/fullscreen.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/client/src/assets/icons/widget/camera/microphone-muted.svg b/app/client/src/assets/icons/widget/camera/microphone-muted.svg
new file mode 100755
index 0000000000..1f64cef1a6
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/microphone-muted.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/client/src/assets/icons/widget/camera/microphone.svg b/app/client/src/assets/icons/widget/camera/microphone.svg
new file mode 100755
index 0000000000..0371b857a7
--- /dev/null
+++ b/app/client/src/assets/icons/widget/camera/microphone.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx
index 6dbd6732ff..24fd4d9ffc 100644
--- a/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx
+++ b/app/client/src/constants/AppsmithActionConstants/ActionConstants.tsx
@@ -92,6 +92,9 @@ export enum EventType {
ON_RECORDING_COMPLETE = "ON_RECORDING_COMPLETE",
ON_SWITCH_GROUP_SELECTION_CHANGE = "ON_SWITCH_GROUP_SELECTION_CHANGE",
ON_JS_FUNCTION_EXECUTE = "ON_JS_FUNCTION_EXECUTE",
+ ON_CAMERA_IMAGE_CAPTURE = "ON_CAMERA_IMAGE_CAPTURE",
+ ON_CAMERA_VIDEO_RECORDING_START = "ON_CAMERA_VIDEO_RECORDING_START",
+ ON_CAMERA_VIDEO_RECORDING_STOP = "ON_CAMERA_VIDEO_RECORDING_STOP",
}
export interface PageAction {
diff --git a/app/client/src/globalStyles/tooltip.ts b/app/client/src/globalStyles/tooltip.ts
index 1eb98effd7..77c42b3e33 100644
--- a/app/client/src/globalStyles/tooltip.ts
+++ b/app/client/src/globalStyles/tooltip.ts
@@ -8,9 +8,10 @@ export const GLOBAL_STYLE_TOOLTIP_CLASSNAME = "ads-global-tooltip";
export const TooltipStyles = createGlobalStyle<{
theme: Theme;
}>`
- .${Classes.PORTAL} {
- .${Classes.TOOLTIP}.${GLOBAL_STYLE_TOOLTIP_CLASSNAME} {
- max-width: 350px;
+ .${Classes.PORTAL} .${Classes.TOOLTIP}.${GLOBAL_STYLE_TOOLTIP_CLASSNAME}, .${
+ Classes.TOOLTIP
+}.${GLOBAL_STYLE_TOOLTIP_CLASSNAME} {
+ max-width: 350px;
overflow-wrap: anywhere;
.${Classes.POPOVER_CONTENT} {
padding: 10px 12px;
@@ -30,6 +31,5 @@ export const TooltipStyles = createGlobalStyle<{
.${CsClasses.BP3_POPOVER_ARROW_FILL} {
fill: ${(props) => props.theme.colors.tooltip.darkBg};
}
- }
}
`;
diff --git a/app/client/src/icons/WidgetIcons.tsx b/app/client/src/icons/WidgetIcons.tsx
index b0dbbbae45..3d3df1bd5d 100644
--- a/app/client/src/icons/WidgetIcons.tsx
+++ b/app/client/src/icons/WidgetIcons.tsx
@@ -1,4 +1,7 @@
import React, { JSXElementConstructor } from "react";
+import styled from "styled-components";
+
+import { Colors } from "constants/Colors";
import { IconProps, IconWrapper } from "constants/IconConstants";
import { ReactComponent as SpinnerIcon } from "assets/icons/widget/alert.svg";
import { ReactComponent as ButtonIcon } from "assets/icons/widget/button.svg";
@@ -35,8 +38,7 @@ import { ReactComponent as CheckboxGroupIcon } from "assets/icons/widget/checkbo
import { ReactComponent as AudioRecorderIcon } from "assets/icons/widget/audio-recorder.svg";
import { ReactComponent as ButtonGroupIcon } from "assets/icons/widget/button-group.svg";
import { ReactComponent as SwitchGroupIcon } from "assets/icons/widget/switch-group.svg";
-import styled from "styled-components";
-import { Colors } from "constants/Colors";
+import { ReactComponent as CameraIcon } from "assets/icons/widget/camera.svg";
/* eslint-disable react/display-name */
@@ -231,6 +233,11 @@ export const WidgetIcons: {
),
+ CAMERA_WIDGET: (props: IconProps) => (
+
+
+
+ ),
};
export type WidgetIcon = typeof WidgetIcons[keyof typeof WidgetIcons];
diff --git a/app/client/src/utils/AppsmithUtils.tsx b/app/client/src/utils/AppsmithUtils.tsx
index 83b110785c..c4b4f7d49c 100644
--- a/app/client/src/utils/AppsmithUtils.tsx
+++ b/app/client/src/utils/AppsmithUtils.tsx
@@ -418,3 +418,34 @@ export const getPageURL = (
page.pageId,
);
};
+
+/**
+ * Convert Base64 string to Blob
+ * @param base64Data
+ * @param contentType
+ * @param sliceSize
+ * @returns
+ */
+export const base64ToBlob = (
+ base64Data: string,
+ contentType = "",
+ sliceSize = 512,
+) => {
+ const byteCharacters = atob(base64Data);
+ const byteArrays = [];
+
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
+
+ const byteNumbers = new Array(slice.length);
+ for (let i = 0; i < slice.length; i++) {
+ byteNumbers[i] = slice.charCodeAt(i);
+ }
+
+ const byteArray = new Uint8Array(byteNumbers);
+ byteArrays.push(byteArray);
+ }
+
+ const blob = new Blob(byteArrays, { type: contentType });
+ return blob;
+};
diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx
index bd3975dfe4..9bd545a788 100644
--- a/app/client/src/utils/WidgetRegistry.tsx
+++ b/app/client/src/utils/WidgetRegistry.tsx
@@ -114,6 +114,10 @@ import SwitchGroupWidget, {
import log from "loglevel";
+import CameraWidget, {
+ CONFIG as CAMERA_WIDGET_CONFIG,
+} from "widgets/CameraWidget";
+
export const registerWidgets = () => {
const start = performance.now();
registerWidget(CanvasWidget, CANVAS_WIDGET_CONFIG);
@@ -158,6 +162,7 @@ export const registerWidgets = () => {
registerWidget(SingleSelectTreeWidget, SINGLE_SELECT_TREE_WIDGET_CONFIG);
registerWidget(SwitchGroupWidget, SWITCH_GROUP_WIDGET_CONFIG);
registerWidget(AudioWidget, AUDIO_WIDGET_CONFIG);
+ registerWidget(CameraWidget, CAMERA_WIDGET_CONFIG);
log.debug("Widget registration took: ", performance.now() - start, "ms");
};
diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts
index ed6307a4d4..7f3cfd0090 100644
--- a/app/client/src/utils/autocomplete/EntityDefinitions.ts
+++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts
@@ -430,6 +430,17 @@ export const entityDefinitions: Record = {
"!url": "https://docs.appsmith.com/widget-reference/switch-group",
selectedValues: "[string]",
},
+ CAMERA_WIDGET: {
+ "!doc":
+ "Camera widget allows users to take a picture or record videos through their system camera using browser permissions.",
+ "!url": "https://docs.appsmith.com/widget-reference/camera",
+ imageBlobURL: "string",
+ imageDataURL: "string",
+ imageRawBinary: "string",
+ videoBlobURL: "string",
+ videoDataURL: "string",
+ videoRawBinary: "string",
+ },
};
export const GLOBAL_DEFS = {
diff --git a/app/client/src/widgets/CameraWidget/component/index.tsx b/app/client/src/widgets/CameraWidget/component/index.tsx
new file mode 100644
index 0000000000..5fb9269050
--- /dev/null
+++ b/app/client/src/widgets/CameraWidget/component/index.tsx
@@ -0,0 +1,1207 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import styled, { css } from "styled-components";
+import { Button, Icon, Menu, MenuItem, Position } from "@blueprintjs/core";
+import { Popover2 } from "@blueprintjs/popover2";
+import Webcam from "react-webcam";
+import { useStopwatch } from "react-timer-hook";
+import {
+ FullScreen,
+ FullScreenHandle,
+ useFullScreenHandle,
+} from "react-full-screen";
+
+import { ThemeProp } from "components/ads/common";
+import {
+ ButtonBorderRadius,
+ ButtonBorderRadiusTypes,
+ ButtonVariant,
+ ButtonVariantTypes,
+} from "components/constants";
+import { SupportedLayouts } from "reducers/entityReducers/pageListReducer";
+import { getCurrentApplicationLayout } from "selectors/editorSelectors";
+import { useSelector } from "store";
+import { Colors } from "constants/Colors";
+import TooltipComponent from "components/ads/Tooltip";
+
+import {
+ CameraMode,
+ CameraModeTypes,
+ DeviceType,
+ DeviceTypes,
+ MediaCaptureAction,
+ MediaCaptureActionTypes,
+ MediaCaptureStatus,
+ MediaCaptureStatusTypes,
+} from "../constants";
+import { ReactComponent as CameraOfflineIcon } from "assets/icons/widget/camera/camera-offline.svg";
+import { ReactComponent as CameraIcon } from "assets/icons/widget/camera/camera.svg";
+import { ReactComponent as CameraMutedIcon } from "assets/icons/widget/camera/camera-muted.svg";
+import { ReactComponent as MicrophoneIcon } from "assets/icons/widget/camera/microphone.svg";
+import { ReactComponent as MicrophoneMutedIcon } from "assets/icons/widget/camera/microphone-muted.svg";
+import { ReactComponent as FullScreenIcon } from "assets/icons/widget/camera/fullscreen.svg";
+import { ReactComponent as ExitFullScreenIcon } from "assets/icons/widget/camera/exit-fullscreen.svg";
+
+const overlayerMixin = css`
+ position: absolute;
+ width: 100%;
+ left: 0;
+ top: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export interface CameraContainerProps {
+ disabled: boolean;
+ scaleAxis: "x" | "y";
+}
+
+const CameraContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ ${({ disabled }) => disabled && `background: ${Colors.GREY_3}`};
+
+ .fullscreen {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+
+ span.error-text {
+ color: ${Colors.GREY_8};
+ }
+ }
+
+ video {
+ max-width: none;
+ ${({ scaleAxis }) => (scaleAxis === "x" ? `width: 100%` : `height: 100%`)};
+ }
+
+ .fullscreen-enabled {
+ video {
+ height: 100%;
+ }
+ }
+`;
+
+export interface DisabledOverlayerProps {
+ disabled: boolean;
+}
+
+const DisabledOverlayer = styled.div`
+ ${overlayerMixin}
+ display: ${({ disabled }) => (disabled ? `flex` : `none`)};
+ height: 100%;
+ z-index: 2;
+ background: ${Colors.GREY_3};
+`;
+
+const PhotoViewer = styled.img`
+ ${overlayerMixin}
+ height: 100%;
+`;
+
+const VideoPlayer = styled.video`
+ ${overlayerMixin}
+`;
+
+const ControlPanelContainer = styled.div`
+ width: 100%;
+`;
+
+export interface ControlPanelOverlayerProps {
+ appLayoutType?: SupportedLayouts;
+}
+
+const ControlPanelOverlayer = styled.div`
+ position: absolute;
+ width: 100%;
+ left: 0;
+ bottom: 0;
+ padding: 1%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ flex-direction: ${({ appLayoutType }) =>
+ appLayoutType === "MOBILE" ? `column` : `row`};
+`;
+
+const MediaInputsContainer = styled.div`
+ display: flex;
+ flex: 1;
+ justify-content: flex-start;
+`;
+
+const MainControlContainer = styled.div`
+ display: flex;
+ flex: 1;
+ justify-content: center;
+`;
+
+const FullscreenContainer = styled.div`
+ display: flex;
+ flex: 1;
+ justify-content: flex-end;
+`;
+
+const TimerContainer = styled.div`
+ position: absolute;
+ top: 2%;
+ padding: 1%;
+ background: #4b4848;
+ color: #ffffff;
+`;
+
+export interface StyledButtonProps {
+ variant: ButtonVariant;
+ borderRadius: ButtonBorderRadius;
+}
+
+const StyledButton = styled(Button)`
+ z-index: 1;
+ height: 32px;
+ width: 32px;
+ margin: 0 1%;
+ box-shadow: none !important;
+ ${({ borderRadius }) =>
+ borderRadius === ButtonBorderRadiusTypes.CIRCLE &&
+ `
+ border-radius: 50%;
+ `}
+ border: ${({ variant }) =>
+ variant === ButtonVariantTypes.SECONDARY ? `1px solid white` : `none`};
+ background: ${({ theme, variant }) =>
+ variant === ButtonVariantTypes.PRIMARY
+ ? theme.colors.button.primary.primary.bgColor
+ : `none`} !important;
+`;
+
+export interface ControlPanelProps {
+ mode: CameraMode;
+ audioInputs: MediaDeviceInfo[];
+ audioMuted: boolean;
+ videoMuted: boolean;
+ videoInputs: MediaDeviceInfo[];
+ status: MediaCaptureStatus;
+ appLayoutType?: SupportedLayouts;
+ fullScreenHandle: FullScreenHandle;
+ onCaptureImage: () => void;
+ onError: (errorMessage: string) => void;
+ onMediaInputChange: (mediaDeviceInfo: MediaDeviceInfo) => void;
+ onRecordingStart: () => void;
+ onRecordingStop: () => void;
+ onResetMedia: () => void;
+ onStatusChange: (status: MediaCaptureStatus) => void;
+ onToggleAudio: (isMute: boolean) => void;
+ onToggleVideo: (isMute: boolean) => void;
+ onVideoPlay: () => void;
+ onVideoPause: () => void;
+}
+
+function ControlPanel(props: ControlPanelProps) {
+ const {
+ appLayoutType,
+ audioInputs,
+ audioMuted,
+ fullScreenHandle,
+ mode,
+ onCaptureImage,
+ onError,
+ onMediaInputChange,
+ onRecordingStart,
+ onRecordingStop,
+ onResetMedia,
+ onStatusChange,
+ onToggleAudio,
+ onToggleVideo,
+ onVideoPause,
+ onVideoPlay,
+ status,
+ videoInputs,
+ videoMuted,
+ } = props;
+
+ const handleControlClick = (action: MediaCaptureAction) => {
+ return () => {
+ switch (action) {
+ case MediaCaptureActionTypes.IMAGE_CAPTURE:
+ // First, check for media device permissions
+ navigator.mediaDevices
+ .getUserMedia({ video: true, audio: false })
+ .then(() => {
+ onCaptureImage();
+ onStatusChange(MediaCaptureStatusTypes.IMAGE_CAPTURED);
+ })
+ .catch((err) => {
+ onError(err.message);
+ });
+
+ break;
+ case MediaCaptureActionTypes.IMAGE_SAVE:
+ onStatusChange(MediaCaptureStatusTypes.IMAGE_SAVED);
+ break;
+ case MediaCaptureActionTypes.IMAGE_DISCARD:
+ onResetMedia();
+ onStatusChange(MediaCaptureStatusTypes.IMAGE_DEFAULT);
+ break;
+ case MediaCaptureActionTypes.IMAGE_REFRESH:
+ onResetMedia();
+ onStatusChange(MediaCaptureStatusTypes.IMAGE_DEFAULT);
+ break;
+
+ case MediaCaptureActionTypes.RECORDING_START:
+ // First, check for media device permissions
+ navigator.mediaDevices
+ .getUserMedia({ video: true, audio: true })
+ .then(() => {
+ onRecordingStart();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_RECORDING);
+ })
+ .catch((err) => {
+ onError(err.message);
+ });
+
+ break;
+ case MediaCaptureActionTypes.RECORDING_STOP:
+ onRecordingStop();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_CAPTURED);
+ break;
+ case MediaCaptureActionTypes.RECORDING_DISCARD:
+ onResetMedia();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_DEFAULT);
+ break;
+ case MediaCaptureActionTypes.RECORDING_SAVE:
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_SAVED);
+ break;
+ case MediaCaptureActionTypes.VIDEO_PLAY:
+ onVideoPlay();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_PLAYING);
+ break;
+ case MediaCaptureActionTypes.VIDEO_PAUSE:
+ onVideoPause();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_PAUSED);
+ break;
+ case MediaCaptureActionTypes.VIDEO_PLAY_AFTER_SAVE:
+ onVideoPlay();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_PLAYING_AFTER_SAVE);
+ break;
+ case MediaCaptureActionTypes.VIDEO_PAUSE_AFTER_SAVE:
+ onVideoPause();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_PAUSED_AFTER_SAVE);
+ break;
+ case MediaCaptureActionTypes.VIDEO_REFRESH:
+ onResetMedia();
+ onStatusChange(MediaCaptureStatusTypes.VIDEO_DEFAULT);
+ break;
+ default:
+ break;
+ }
+ };
+ };
+
+ const renderMediaDeviceSelectors = () => {
+ return (
+ <>
+ {mode === CameraModeTypes.VIDEO && (
+
+ )}
+
+ >
+ );
+ };
+
+ const renderControls = () => {
+ switch (status) {
+ case MediaCaptureStatusTypes.IMAGE_DEFAULT:
+ return (
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.IMAGE_CAPTURE,
+ )}
+ variant={ButtonVariantTypes.SECONDARY}
+ />
+
+ );
+ break;
+
+ case MediaCaptureStatusTypes.IMAGE_CAPTURED:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(MediaCaptureActionTypes.IMAGE_SAVE)}
+ variant={ButtonVariantTypes.PRIMARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.IMAGE_DISCARD,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.IMAGE_SAVED:
+ return (
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.IMAGE_REFRESH,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_DEFAULT:
+ return (
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_START,
+ )}
+ variant={ButtonVariantTypes.SECONDARY}
+ />
+
+ );
+ break;
+ case MediaCaptureStatusTypes.VIDEO_RECORDING:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_STOP,
+ )}
+ variant={ButtonVariantTypes.SECONDARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_DISCARD,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_CAPTURED:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_SAVE,
+ )}
+ variant={ButtonVariantTypes.PRIMARY}
+ />
+
+
+ }
+ onClick={handleControlClick(MediaCaptureActionTypes.VIDEO_PLAY)}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_DISCARD,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_PLAYING:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_SAVE,
+ )}
+ variant={ButtonVariantTypes.PRIMARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_PAUSE,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_DISCARD,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_PAUSED:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_SAVE,
+ )}
+ variant={ButtonVariantTypes.PRIMARY}
+ />
+
+
+ }
+ onClick={handleControlClick(MediaCaptureActionTypes.VIDEO_PLAY)}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.RECORDING_DISCARD,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_SAVED:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_PLAY_AFTER_SAVE,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_REFRESH,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_PLAYING_AFTER_SAVE:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_PAUSE_AFTER_SAVE,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_REFRESH,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ case MediaCaptureStatusTypes.VIDEO_PAUSED_AFTER_SAVE:
+ return (
+ <>
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_PLAY_AFTER_SAVE,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+
+ }
+ onClick={handleControlClick(
+ MediaCaptureActionTypes.VIDEO_REFRESH,
+ )}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ >
+ );
+ break;
+
+ default:
+ break;
+ }
+ };
+
+ const renderFullscreenControl = () => {
+ return fullScreenHandle.active ? (
+
+ } iconSize={20} />
+ }
+ onClick={fullScreenHandle.exit}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ ) : (
+
+ } iconSize={20} />}
+ onClick={fullScreenHandle.enter}
+ variant={ButtonVariantTypes.TERTIARY}
+ />
+
+ );
+ };
+
+ return (
+
+
+
+ {renderMediaDeviceSelectors()}
+
+ {renderControls()}
+ {renderFullscreenControl()}
+
+
+ );
+}
+
+// Timer(recording & playing)
+const getFormattedDigit = (value: number) => {
+ return value >= 10 ? value.toString() : `0${value.toString()}`;
+};
+export interface TimerProps {
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+}
+
+function Timer(props: TimerProps) {
+ const { days, hours, minutes, seconds } = props;
+ return (
+
+ {!!days && {`${getFormattedDigit(days)}:`}}
+ {!!hours && {`${getFormattedDigit(hours)}:`}}
+ {`${getFormattedDigit(minutes)}:`}
+ {`${getFormattedDigit(seconds)}`}
+
+ );
+}
+
+// Device menus (microphone, camera)
+export interface DeviceMenuProps {
+ items: MediaDeviceInfo[];
+ onItemClick: (item: MediaDeviceInfo) => void;
+}
+
+function DeviceMenu(props: DeviceMenuProps) {
+ const { items, onItemClick } = props;
+ return (
+
+ );
+}
+
+export interface DevicePopoverProps {
+ deviceType: DeviceType;
+ disabled?: boolean;
+ items: MediaDeviceInfo[];
+ onDeviceMute?: (isMute: boolean) => void;
+ onItemClick: (item: MediaDeviceInfo) => void;
+}
+
+function DevicePopover(props: DevicePopoverProps) {
+ const { deviceType, disabled, items, onDeviceMute, onItemClick } = props;
+
+ const handleDeviceMute = useCallback(() => {
+ if (onDeviceMute) {
+ onDeviceMute(!disabled);
+ }
+ }, [disabled, onDeviceMute]);
+
+ const renderLeftIcon = (deviceType: DeviceType) => {
+ if (deviceType === DeviceTypes.CAMERA) {
+ if (disabled) {
+ return ;
+ }
+ return ;
+ }
+ if (disabled) {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+ <>
+
+ }
+ >
+ } />
+
+ >
+ );
+}
+
+function CameraComponent(props: CameraComponentProps) {
+ const {
+ disabled,
+ height,
+ mirrored,
+ mode,
+ onImageCapture,
+ onRecordingStart,
+ onRecordingStop,
+ videoBlobURL,
+ width,
+ } = props;
+
+ const webcamRef = useRef(null);
+ const mediaRecorderRef = useRef();
+ const videoElementRef = useRef(null);
+
+ const [scaleAxis, setScaleAxis] = useState<"x" | "y">("x");
+ const [audioInputs, setAudioInputs] = useState([]);
+ const [videoInputs, setVideoInputs] = useState([]);
+ const [audioConstraints, setAudioConstraints] = useState<
+ MediaTrackConstraints
+ >({});
+ const [videoConstraints, setVideoConstraints] = useState<
+ MediaTrackConstraints
+ >({});
+ const [image, setImage] = useState();
+ const [mediaCaptureStatus, setMediaCaptureStatus] = useState<
+ MediaCaptureStatus
+ >(MediaCaptureStatusTypes.IMAGE_DEFAULT);
+ const [isPhotoViewerReady, setIsPhotoViewerReady] = useState(false);
+ const [isVideoPlayerReady, setIsVideoPlayerReady] = useState(false);
+ const [playerDays, setPlayerDays] = useState(0);
+ const [playerHours, setPlayerHours] = useState(0);
+ const [playerMinutes, setPlayerMinutes] = useState(0);
+ const [playerSeconds, setPlayerSeconds] = useState(0);
+ const [isReadyPlayerTimer, setIsReadyPlayerTimer] = useState(false);
+ const [isAudioMuted, setIsAudioMuted] = useState(false);
+ const [isVideoMuted, setIsVideoMuted] = useState(false);
+ const [error, setError] = useState("");
+ const { days, hours, minutes, pause, reset, seconds, start } = useStopwatch({
+ autoStart: false,
+ });
+ const fullScreenHandle = useFullScreenHandle();
+
+ useEffect(() => {
+ navigator.mediaDevices
+ .enumerateDevices()
+ .then(handleDeviceInputs)
+ .catch((err) => {
+ setError(err.message);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (webcamRef.current && webcamRef.current.stream) {
+ updateMediaTracksEnabled(webcamRef.current.stream);
+ }
+ }, [isAudioMuted, isVideoMuted]);
+
+ useEffect(() => {
+ if (width > height) {
+ setScaleAxis("x");
+ return;
+ }
+ setScaleAxis("y");
+ }, [height, width]);
+
+ useEffect(() => {
+ setIsReadyPlayerTimer(false);
+ if (mode === CameraModeTypes.CAMERA) {
+ setMediaCaptureStatus(MediaCaptureStatusTypes.IMAGE_DEFAULT);
+ return;
+ }
+ setMediaCaptureStatus(MediaCaptureStatusTypes.VIDEO_DEFAULT);
+
+ return () => {
+ mediaRecorderRef.current?.removeEventListener(
+ "dataavailable",
+ handleDataAvailable,
+ );
+ };
+ }, [mode]);
+
+ useEffect(() => {
+ onImageCapture(image);
+ }, [image]);
+
+ useEffect(() => {
+ if (videoBlobURL && videoElementRef.current) {
+ videoElementRef.current.src = videoBlobURL;
+ videoElementRef.current.addEventListener("ended", handlePlayerEnded);
+ videoElementRef.current.addEventListener("timeupdate", handleTimeUpdate);
+ }
+
+ return () => {
+ videoElementRef.current?.removeEventListener("ended", handlePlayerEnded);
+ videoElementRef.current?.removeEventListener(
+ "timeupdate",
+ handleTimeUpdate,
+ );
+ };
+ }, [videoBlobURL, videoElementRef.current]);
+
+ useEffect(() => {
+ // Set the flags for previewing the captured photo and video
+ const photoReadyStates: MediaCaptureStatus[] = [
+ MediaCaptureStatusTypes.IMAGE_CAPTURED,
+ MediaCaptureStatusTypes.IMAGE_SAVED,
+ ];
+ const videoReadyStates: MediaCaptureStatus[] = [
+ MediaCaptureStatusTypes.VIDEO_CAPTURED,
+ MediaCaptureStatusTypes.VIDEO_PLAYING,
+ MediaCaptureStatusTypes.VIDEO_PAUSED,
+ MediaCaptureStatusTypes.VIDEO_SAVED,
+ MediaCaptureStatusTypes.VIDEO_PLAYING_AFTER_SAVE,
+ MediaCaptureStatusTypes.VIDEO_PAUSED_AFTER_SAVE,
+ ];
+ setIsPhotoViewerReady(photoReadyStates.includes(mediaCaptureStatus));
+ setIsVideoPlayerReady(videoReadyStates.includes(mediaCaptureStatus));
+ }, [mediaCaptureStatus]);
+
+ const appLayout = useSelector(getCurrentApplicationLayout);
+
+ const handleDeviceInputs = useCallback(
+ (mediaInputs: MediaDeviceInfo[]) => {
+ setAudioInputs(mediaInputs.filter(({ kind }) => kind === "audioinput"));
+ setVideoInputs(mediaInputs.filter(({ kind }) => kind === "videoinput"));
+ },
+ [setAudioInputs, setVideoInputs],
+ );
+
+ const handleMediaDeviceChange = useCallback(
+ (mediaDeviceInfo: MediaDeviceInfo) => {
+ if (mediaDeviceInfo.kind === "audioinput") {
+ setAudioConstraints({
+ ...audioConstraints,
+ deviceId: mediaDeviceInfo.deviceId,
+ });
+ }
+ if (mediaDeviceInfo.kind === "videoinput") {
+ setVideoConstraints({
+ ...videoConstraints,
+ deviceId: mediaDeviceInfo.deviceId,
+ });
+ }
+ },
+ [],
+ );
+
+ const updateMediaTracksEnabled = (stream: MediaStream) => {
+ stream.getAudioTracks()[0].enabled = !isAudioMuted;
+ stream.getVideoTracks()[0].enabled = !isVideoMuted;
+ };
+
+ const captureImage = useCallback(() => {
+ if (webcamRef.current) {
+ const capturedImage = webcamRef.current.getScreenshot();
+ setImage(capturedImage);
+ }
+ }, [webcamRef, setImage]);
+
+ const resetMedia = useCallback(() => {
+ setIsReadyPlayerTimer(false);
+ reset(0, false);
+
+ if (mode === CameraModeTypes.CAMERA) {
+ setImage(null);
+ return;
+ }
+ onRecordingStop(null);
+ }, [mode]);
+
+ const handleStatusChange = useCallback(
+ (status: MediaCaptureStatus) => {
+ setMediaCaptureStatus(status);
+ },
+ [setMediaCaptureStatus],
+ );
+
+ const handleRecordingStart = useCallback(() => {
+ if (webcamRef.current && webcamRef.current.stream) {
+ mediaRecorderRef.current = new MediaRecorder(webcamRef.current.stream, {
+ mimeType: "video/webm",
+ });
+ mediaRecorderRef.current.addEventListener(
+ "dataavailable",
+ handleDataAvailable,
+ );
+ mediaRecorderRef.current.start();
+ start();
+ onRecordingStart();
+ }
+ }, [webcamRef, mediaRecorderRef]);
+
+ const handleDataAvailable = useCallback(
+ ({ data }) => {
+ if (data.size > 0) {
+ onRecordingStop(data);
+ }
+ },
+ [onRecordingStop],
+ );
+
+ const handleRecordingStop = useCallback(() => {
+ mediaRecorderRef.current?.stop();
+ pause();
+ }, [mediaRecorderRef, webcamRef]);
+
+ const handleVideoPlay = useCallback(() => {
+ if (!isReadyPlayerTimer) {
+ reset(0, false);
+ setIsReadyPlayerTimer(true);
+ }
+ videoElementRef.current?.play();
+ }, [videoElementRef]);
+
+ const handleVideoPause = () => {
+ videoElementRef.current?.pause();
+ };
+
+ const handlePlayerEnded = () => {
+ setMediaCaptureStatus((prevStatus) => {
+ switch (prevStatus) {
+ case MediaCaptureStatusTypes.VIDEO_PLAYING_AFTER_SAVE:
+ return MediaCaptureStatusTypes.VIDEO_SAVED;
+ default:
+ return MediaCaptureStatusTypes.VIDEO_CAPTURED;
+ }
+ });
+ };
+
+ const handleTimeUpdate = () => {
+ if (videoElementRef.current) {
+ const totalSeconds = Math.ceil(videoElementRef.current.currentTime);
+
+ setPlayerDays(Math.floor(totalSeconds / (60 * 60 * 24)));
+ setPlayerHours(Math.floor((totalSeconds % (60 * 60 * 24)) / (60 * 60)));
+ setPlayerMinutes(Math.floor((totalSeconds % (60 * 60)) / 60));
+ setPlayerSeconds(Math.floor(totalSeconds % 60));
+ }
+ };
+
+ const handleUserMedia = (stream: MediaStream) => {
+ updateMediaTracksEnabled(stream);
+ };
+
+ const handleUserMediaErrors = useCallback((error: string | DOMException) => {
+ if (typeof error === "string") {
+ setError(error);
+ }
+ setError((error as DOMException).message);
+ }, []);
+
+ const renderTimer = () => {
+ if (mode === CameraModeTypes.VIDEO) {
+ if (isReadyPlayerTimer) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ const renderComponent = () => {
+ if (error) {
+ return (
+ <>
+
+ {error}
+ {error === "Permission denied" && (
+
+ Know more
+
+ )}
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {isPhotoViewerReady && image && }
+
+ {isVideoPlayerReady && }
+
+
+
+ {renderTimer()}
+ >
+ );
+ };
+
+ return (
+
+ {renderComponent()}
+
+ );
+}
+
+export interface CameraComponentProps {
+ disabled: boolean;
+ height: number;
+ mirrored: boolean;
+ mode: CameraMode;
+ onImageCapture: (image?: string | null) => void;
+ onRecordingStart: () => void;
+ onRecordingStop: (video: Blob | null) => void;
+ videoBlobURL?: string;
+ width: number;
+}
+
+export default CameraComponent;
diff --git a/app/client/src/widgets/CameraWidget/constants.ts b/app/client/src/widgets/CameraWidget/constants.ts
new file mode 100644
index 0000000000..c7f25c95b2
--- /dev/null
+++ b/app/client/src/widgets/CameraWidget/constants.ts
@@ -0,0 +1,47 @@
+// This file contains common constants which can be used across the widget configuration file (index.ts), widget and component folders.
+export enum CameraModeTypes {
+ CAMERA = "CAMERA",
+ VIDEO = "VIDEO",
+}
+
+export type CameraMode = keyof typeof CameraModeTypes;
+
+export enum MediaCaptureStatusTypes {
+ IMAGE_DEFAULT = "IMAGE_DEFAULT",
+ IMAGE_CAPTURED = "IMAGE_CAPTURED",
+ IMAGE_SAVED = "IMAGE_SAVED",
+ VIDEO_DEFAULT = "VIDEO_DEFAULT",
+ VIDEO_RECORDING = "VIDEO_RECORDING",
+ VIDEO_CAPTURED = "VIDEO_CAPTURED",
+ VIDEO_PLAYING = "VIDEO_PLAYING",
+ VIDEO_PAUSED = "VIDEO_PAUSED",
+ VIDEO_SAVED = "VIDEO_SAVED",
+ VIDEO_PLAYING_AFTER_SAVE = "VIDEO_PLAYING_AFTER_SAVE",
+ VIDEO_PAUSED_AFTER_SAVE = "VIDEO_PAUSED_AFTER_SAVE",
+}
+
+export type MediaCaptureStatus = keyof typeof MediaCaptureStatusTypes;
+
+export enum MediaCaptureActionTypes {
+ IMAGE_CAPTURE = "IMAGE_CAPTURE",
+ IMAGE_SAVE = "IMAGE_SAVE",
+ IMAGE_DISCARD = "IMAGE_DISCARD",
+ IMAGE_REFRESH = "IMAGE_REFRESH",
+ RECORDING_START = "RECORDING_START",
+ RECORDING_STOP = "RECORDING_STOP",
+ RECORDING_DISCARD = "RECORDING_DISCARD",
+ RECORDING_SAVE = "RECORDING_SAVE",
+ VIDEO_PLAY = "VIDEO_PLAY",
+ VIDEO_PAUSE = "VIDEO_PAUSE",
+ VIDEO_PLAY_AFTER_SAVE = "VIDEO_PLAY_AFTER_SAVE",
+ VIDEO_PAUSE_AFTER_SAVE = "VIDEO_PAUSE_AFTER_SAVE",
+ VIDEO_REFRESH = "VIDEO_REFRESH",
+}
+
+export type MediaCaptureAction = keyof typeof MediaCaptureActionTypes;
+
+export enum DeviceTypes {
+ MICROPHONE = "MICROPHONE",
+ CAMERA = "CAMERA",
+}
+export type DeviceType = keyof typeof DeviceTypes;
diff --git a/app/client/src/widgets/CameraWidget/icon.svg b/app/client/src/widgets/CameraWidget/icon.svg
new file mode 100755
index 0000000000..2a9b5b353b
--- /dev/null
+++ b/app/client/src/widgets/CameraWidget/icon.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/client/src/widgets/CameraWidget/index.ts b/app/client/src/widgets/CameraWidget/index.ts
new file mode 100644
index 0000000000..8fdbce53b9
--- /dev/null
+++ b/app/client/src/widgets/CameraWidget/index.ts
@@ -0,0 +1,30 @@
+import Widget from "./widget";
+import IconSVG from "./icon.svg";
+import { GRID_DENSITY_MIGRATION_V1 } from "widgets/constants";
+import { CameraModeTypes } from "./constants";
+
+export const CONFIG = {
+ type: Widget.getWidgetType(),
+ name: "Camera", // The display name which will be made in uppercase and show in the widgets panel ( can have spaces )
+ iconSVG: IconSVG,
+ needsMeta: true, // Defines if this widget adds any meta properties
+ isCanvas: false, // Defines if this widget has a canvas within in which we can drop other widgets
+ defaults: {
+ widgetName: "Camera",
+ rows: 8.25 * GRID_DENSITY_MIGRATION_V1,
+ columns: 6.25 * GRID_DENSITY_MIGRATION_V1,
+ mode: CameraModeTypes.CAMERA,
+ isDisabled: false,
+ isVisible: true,
+ isMirrored: true,
+ version: 1,
+ },
+ properties: {
+ derived: Widget.getDerivedPropertiesMap(),
+ default: Widget.getDefaultPropertiesMap(),
+ meta: Widget.getMetaPropertiesMap(),
+ config: Widget.getPropertyPaneConfig(),
+ },
+};
+
+export default Widget;
diff --git a/app/client/src/widgets/CameraWidget/widget/index.tsx b/app/client/src/widgets/CameraWidget/widget/index.tsx
new file mode 100644
index 0000000000..61563bcb6e
--- /dev/null
+++ b/app/client/src/widgets/CameraWidget/widget/index.tsx
@@ -0,0 +1,275 @@
+import React from "react";
+
+import BaseWidget, { WidgetProps, WidgetState } from "widgets/BaseWidget";
+import { DerivedPropertiesMap } from "utils/WidgetFactory";
+import { ValidationTypes } from "constants/WidgetValidation";
+import { WIDGET_PADDING } from "constants/WidgetConstants";
+import { EventType } from "constants/AppsmithActionConstants/ActionConstants";
+import { base64ToBlob, createBlobUrl } from "utils/AppsmithUtils";
+import { FileDataTypes } from "widgets/constants";
+
+import CameraComponent from "../component";
+import {
+ CameraMode,
+ CameraModeTypes,
+ MediaCaptureStatusTypes,
+} from "../constants";
+
+class CameraWidget extends BaseWidget {
+ static getPropertyPaneConfig() {
+ return [
+ {
+ sectionName: "General",
+ children: [
+ {
+ propertyName: "mode",
+ label: "Mode",
+ controlType: "DROP_DOWN",
+ helpText: "Whether a picture is taken or a video is recorded",
+ options: [
+ {
+ label: "Camera",
+ value: "CAMERA",
+ },
+ {
+ label: "Video",
+ value: "VIDEO",
+ },
+ ],
+ isBindProperty: false,
+ isTriggerProperty: false,
+ validation: {
+ type: ValidationTypes.TEXT,
+ params: {
+ allowedValues: ["CAMERA", "VIDEO"],
+ },
+ },
+ },
+ {
+ propertyName: "isDisabled",
+ label: "Disabled",
+ controlType: "SWITCH",
+ helpText: "Disables clicks to this widget",
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: false,
+ validation: { type: ValidationTypes.BOOLEAN },
+ },
+ {
+ propertyName: "isVisible",
+ label: "Visible",
+ helpText: "Controls the visibility of the widget",
+ controlType: "SWITCH",
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: false,
+ validation: { type: ValidationTypes.BOOLEAN },
+ },
+ {
+ propertyName: "isMirrored",
+ label: "Mirrored",
+ helpText: "Show camera preview and get the screenshot mirrored",
+ controlType: "SWITCH",
+ hidden: (props: CameraWidgetProps) =>
+ props.mode === CameraModeTypes.VIDEO,
+ dependencies: ["mode"],
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: false,
+ validation: { type: ValidationTypes.BOOLEAN },
+ },
+ ],
+ },
+ {
+ sectionName: "Actions",
+ children: [
+ {
+ helpText: "Triggers an action when the image is captured",
+ propertyName: "onImageCapture",
+ label: "OnImageCapture",
+ controlType: "ACTION_SELECTOR",
+ hidden: (props: CameraWidgetProps) =>
+ props.mode === CameraModeTypes.VIDEO,
+ dependencies: ["mode"],
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: true,
+ },
+ {
+ helpText: "Triggers an action when the video recording get started",
+ propertyName: "onRecordingStart",
+ label: "OnRecordingStart",
+ controlType: "ACTION_SELECTOR",
+ hidden: (props: CameraWidgetProps) =>
+ props.mode === CameraModeTypes.CAMERA,
+ dependencies: ["mode"],
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: true,
+ },
+ {
+ helpText: "Triggers an action when the video recording stops",
+ propertyName: "onRecordingStop",
+ label: "OnRecordingStop",
+ controlType: "ACTION_SELECTOR",
+ hidden: (props: CameraWidgetProps) =>
+ props.mode === CameraModeTypes.CAMERA,
+ dependencies: ["mode"],
+ isJSConvertible: true,
+ isBindProperty: true,
+ isTriggerProperty: true,
+ },
+ ],
+ },
+ ];
+ }
+
+ static getDerivedPropertiesMap(): DerivedPropertiesMap {
+ return {};
+ }
+
+ static getDefaultPropertiesMap(): Record {
+ return {};
+ }
+
+ static getMetaPropertiesMap(): Record {
+ return {
+ image: null,
+ imageBlobURL: undefined,
+ imageDataURL: undefined,
+ imageRawBinary: undefined,
+ mediaCaptureStatus: MediaCaptureStatusTypes.IMAGE_DEFAULT,
+ timer: undefined,
+ videoBlobURL: undefined,
+ videoDataURL: undefined,
+ videoRawBinary: undefined,
+ };
+ }
+
+ static getWidgetType(): string {
+ return "CAMERA_WIDGET";
+ }
+
+ getPageView() {
+ const {
+ bottomRow,
+ isDisabled,
+ isMirrored,
+ leftColumn,
+ mode,
+ parentColumnSpace,
+ parentRowSpace,
+ rightColumn,
+ topRow,
+ videoBlobURL,
+ } = this.props;
+
+ const height = (bottomRow - topRow) * parentRowSpace - WIDGET_PADDING * 2;
+ const width =
+ (rightColumn - leftColumn) * parentColumnSpace - WIDGET_PADDING * 2;
+
+ return (
+
+ );
+ }
+
+ handleImageCapture = (image?: string | null) => {
+ if (!image) {
+ URL.revokeObjectURL(this.props.imageBlobURL);
+
+ this.props.updateWidgetMetaProperty("imageBlobURL", undefined);
+ this.props.updateWidgetMetaProperty("imageDataURL", undefined);
+ this.props.updateWidgetMetaProperty("imageRawBinary", undefined);
+ return;
+ }
+ const base64Data = image.split(",")[1];
+ const imageBlob = base64ToBlob(base64Data, "image/webp");
+ const blobURL = URL.createObjectURL(imageBlob);
+ const blobIdForBase64 = createBlobUrl(imageBlob, FileDataTypes.Base64);
+ const blobIdForRaw = createBlobUrl(imageBlob, FileDataTypes.Binary);
+
+ this.props.updateWidgetMetaProperty("imageBlobURL", blobURL);
+ this.props.updateWidgetMetaProperty("imageDataURL", blobIdForBase64, {
+ triggerPropertyName: "onImageCapture",
+ dynamicString: this.props.onImageCapture,
+ event: {
+ type: EventType.ON_CAMERA_IMAGE_CAPTURE,
+ },
+ });
+ this.props.updateWidgetMetaProperty("imageRawBinary", blobIdForRaw, {
+ triggerPropertyName: "onImageCapture",
+ dynamicString: this.props.onImageCapture,
+ event: {
+ type: EventType.ON_CAMERA_IMAGE_CAPTURE,
+ },
+ });
+ };
+
+ handleRecordingStart = () => {
+ if (this.props.onRecordingStart) {
+ super.executeAction({
+ triggerPropertyName: "onRecordingStart",
+ dynamicString: this.props.onRecordingStart,
+ event: {
+ type: EventType.ON_CAMERA_VIDEO_RECORDING_START,
+ },
+ });
+ }
+ };
+
+ handleRecordingStop = (video?: Blob | null) => {
+ if (!video) {
+ if (this.props.videoBlobURL) {
+ URL.revokeObjectURL(this.props.videoBlobURL);
+ }
+
+ this.props.updateWidgetMetaProperty("videoBlobURL", undefined);
+ this.props.updateWidgetMetaProperty("videoDataURL", undefined);
+ this.props.updateWidgetMetaProperty("videoRawBinary", undefined);
+ return;
+ }
+
+ const blobURL = URL.createObjectURL(video);
+ const blobIdForBase64 = createBlobUrl(video, FileDataTypes.Base64);
+ const blobIdForRaw = createBlobUrl(video, FileDataTypes.Binary);
+
+ this.props.updateWidgetMetaProperty("videoBlobURL", blobURL);
+ this.props.updateWidgetMetaProperty("videoDataURL", blobIdForBase64, {
+ triggerPropertyName: "onRecordingStop",
+ dynamicString: this.props.onRecordingStop,
+ event: {
+ type: EventType.ON_CAMERA_VIDEO_RECORDING_STOP,
+ },
+ });
+ this.props.updateWidgetMetaProperty("videoRawBinary", blobIdForRaw, {
+ triggerPropertyName: "onRecordingStop",
+ dynamicString: this.props.onRecordingStop,
+ event: {
+ type: EventType.ON_CAMERA_VIDEO_RECORDING_STOP,
+ },
+ });
+ };
+}
+
+export interface CameraWidgetProps extends WidgetProps {
+ isDisabled: boolean;
+ isMirrored: boolean;
+ isVisible: boolean;
+ mode: CameraMode;
+ onImageCapture?: string;
+ onRecordingStart?: string;
+ onRecordingStop?: string;
+ videoBlobURL?: string;
+}
+
+export default CameraWidget;
diff --git a/app/client/yarn.lock b/app/client/yarn.lock
index 8ec2173db5..4ca9668798 100644
--- a/app/client/yarn.lock
+++ b/app/client/yarn.lock
@@ -2771,6 +2771,11 @@
version "1.0.0"
resolved "https://registry.npmjs.org/@types/deep-diff/-/deep-diff-1.0.0.tgz"
+"@types/dom-mediacapture-record@^1.0.11":
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.11.tgz#f61b17e6131d76629d4039b02634c7e786b82c3a"
+ integrity sha512-ODVOH95x08arZhbQOjH3no7Iye64akdO+55nM+IGtTzpu2ACKr9CQTrI//CCVieIjlI/eL+rK1hQjMycxIgylQ==
+
"@types/dom4@^2.0.1":
version "2.0.1"
resolved "https://registry.npmjs.org/@types/dom4/-/dom4-2.0.1.tgz"
@@ -8038,6 +8043,11 @@ fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
+fscreen@^1.0.2:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/fscreen/-/fscreen-1.2.0.tgz#1a8c88e06bc16a07b473ad96196fb06d6657f59e"
+ integrity sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg==
+
fsevents@^1.2.7:
version "1.2.13"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz"
@@ -13598,6 +13608,13 @@ react-fast-compare@^3.0.0, react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
+react-full-screen@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.0.tgz#696c586da25652ed10d88046f8dc0b6620f4ef96"
+ integrity sha512-ivL/HrcfHhEUJWmgoiDKP7Xfy127LGz9x3VnwVxljJ0ky1D1YqJmXjhxnuEhfqT3yociJy/HCk9/yyJ3HEAjaw==
+ dependencies:
+ fscreen "^1.0.2"
+
react-google-maps@^9.4.5:
version "9.4.5"
resolved "https://registry.npmjs.org/react-google-maps/-/react-google-maps-9.4.5.tgz"
@@ -13984,6 +14001,11 @@ react-virtuoso@^1.9.0:
react-app-polyfill "^1.0.6"
resize-observer-polyfill "^1.5.1"
+react-webcam@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-6.0.0.tgz#46dd9ba44cebe6bf3cc4ea2ff09d12243900abfa"
+ integrity sha512-pw7067WYnDHRjAXYXrsLeig9/AAxCFDnnaEJzZ5ep6UZoYMqF4UNRtVkeTk0LotpwqT/c8vHisn/+QodNbUsQA==
+
react-window@^1.8.2:
version "1.8.5"
resolved "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz"