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 ( + + {items.map((item: MediaDeviceInfo) => { + return ( + onItemClick(item)} + text={item.label || item.deviceId} + /> + ); + })} + + ); +} + +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 ( + <> +