diff --git a/app/client/src/assets/icons/widget/video.svg b/app/client/src/assets/icons/widget/video.svg new file mode 100644 index 0000000000..11426cec91 --- /dev/null +++ b/app/client/src/assets/icons/widget/video.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/designSystems/appsmith/PopoverVideo.tsx b/app/client/src/components/designSystems/appsmith/PopoverVideo.tsx new file mode 100644 index 0000000000..57648add7c --- /dev/null +++ b/app/client/src/components/designSystems/appsmith/PopoverVideo.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { + Popover, + PopoverInteractionKind, + PopoverPosition, +} from "@blueprintjs/core"; +import { Colors } from "constants/Colors"; +import VideoComponent, { VideoComponentProps } from "./VideoComponent"; +import styled, { AnyStyledComponent } from "styled-components"; +import { ControlIcons } from "icons/ControlIcons"; +const PlayIcon = styled(ControlIcons.PLAY_VIDEO as AnyStyledComponent)` + position: relative; + top: 10px; + cursor: pointer; + &:hover { + svg { + path { + fill: ${Colors.POMEGRANATE}; + } + } + } +`; + +const PlayerWrapper = styled.div` import React, { Ref } from "react"; + width: 600px; + height: 400px; +`; + +const PopoverVideo = (props: VideoComponentProps) => { + return ( +
e.stopPropagation()}> + + + + + + +
+ ); +}; + +export default PopoverVideo; diff --git a/app/client/src/components/designSystems/appsmith/TableUtilities.tsx b/app/client/src/components/designSystems/appsmith/TableUtilities.tsx index 69b51d2cb5..14e379d213 100644 --- a/app/client/src/components/designSystems/appsmith/TableUtilities.tsx +++ b/app/client/src/components/designSystems/appsmith/TableUtilities.tsx @@ -15,7 +15,7 @@ import { Condition, } from "widgets/TableWidget"; import { isString } from "lodash"; -import VideoComponent from "components/designSystems/appsmith/VideoComponent"; +import PopoverVideo from "components/designSystems/appsmith/PopoverVideo"; import Button from "components/editorComponents/Button"; import AutoToolTipComponent from "components/designSystems/appsmith/AutoToolTipComponent"; import TableColumnMenuPopup from "./TableColumnMenu"; @@ -505,7 +505,7 @@ export const renderCell = ( } else if (isString(value) && youtubeRegex.test(value)) { return ( - + ); } else { diff --git a/app/client/src/components/designSystems/appsmith/VideoComponent.tsx b/app/client/src/components/designSystems/appsmith/VideoComponent.tsx index 08576ec847..414475c0b3 100644 --- a/app/client/src/components/designSystems/appsmith/VideoComponent.tsx +++ b/app/client/src/components/designSystems/appsmith/VideoComponent.tsx @@ -1,73 +1,72 @@ -import React from "react"; import ReactPlayer from "react-player"; -import { - Popover, - PopoverInteractionKind, - PopoverPosition, -} from "@blueprintjs/core"; -import { ControlIcons } from "icons/ControlIcons"; -import styled, { AnyStyledComponent } from "styled-components"; -import { Colors } from "constants/Colors"; - -const PlayerWrapper = styled.div` - width: 600px; - height: 400px; -`; - -const PlayIcon = styled(ControlIcons.PLAY_VIDEO as AnyStyledComponent)` - position: relative; - top: 10px; - &:hover { - svg { - path { - fill: ${Colors.POMEGRANATE}; - } - } - } -`; - -interface VideoComponentProps { - url: string; +import React, { Ref } from "react"; +import styled from "styled-components"; +import { ENTER_VIDEO_URL } from "constants/messages"; +export interface VideoComponentProps { + url?: string; + autoplay?: boolean; + controls?: boolean; + onStart?: () => void; + onPlay?: () => void; + onPause?: () => void; + onEnded?: () => void; + onReady?: () => void; + onProgress?: () => void; + onSeek?: () => void; + onError?: () => void; + player?: Ref; } -const VideoComponent = (props: VideoComponentProps) => { - return ( -
e.stopPropagation()}> - - - - - - -
- ); -}; +const ErrorContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +`; -export default VideoComponent; +const Error = styled.span``; + +export default function VideoComponent(props: VideoComponentProps) { + const { + url, + autoplay, + controls, + onStart, + onPlay, + onPause, + onEnded, + onReady, + onProgress, + onSeek, + onError, + player, + } = props; + return ( + <> + {url ? ( + + ) : ( + + {ENTER_VIDEO_URL} + + )} + + ); +} diff --git a/app/client/src/constants/ActionConstants.tsx b/app/client/src/constants/ActionConstants.tsx index b84e46646d..88a5697d16 100644 --- a/app/client/src/constants/ActionConstants.tsx +++ b/app/client/src/constants/ActionConstants.tsx @@ -37,6 +37,10 @@ export enum EventType { ON_MARKER_CLICK = "ON_MARKER_CLICK", ON_CREATE_MARKER = "ON_CREATE_MARKER", ON_TAB_CHANGE = "ON_TAB_CHANGE", + ON_VIDEO_START = "ON_VIDEO_START", + ON_VIDEO_END = "ON_VIDEO_END", + ON_VIDEO_PLAY = "ON_VIDEO_PLAY", + ON_VIDEO_PAUSE = "ON_VIDEO_PAUSE", } export type ActionType = diff --git a/app/client/src/constants/FieldExpectedValue.ts b/app/client/src/constants/FieldExpectedValue.ts index c9ac6d44fc..6867a4ddec 100644 --- a/app/client/src/constants/FieldExpectedValue.ts +++ b/app/client/src/constants/FieldExpectedValue.ts @@ -33,6 +33,11 @@ const FIELD_VALUES: Record< // onRowSelected: "Function Call", // onPageChange: "Function Call", }, + VIDEO_WIDGET: { + url: "string", + autoPlay: "boolean", + isVisible: "boolean", + }, IMAGE_WIDGET: { image: "string", defaultImage: "string", diff --git a/app/client/src/constants/HelpConstants.ts b/app/client/src/constants/HelpConstants.ts index aad2817853..2d460d0e15 100644 --- a/app/client/src/constants/HelpConstants.ts +++ b/app/client/src/constants/HelpConstants.ts @@ -31,6 +31,10 @@ export const HelpMap = { path: "/widget-reference/table", searchKey: "Table", }, + VIDEO_WIDGET: { + path: "/widget-reference/video", + searchKey: "Video", + }, DROP_DOWN_WIDGET: { path: "/widget-reference/dropdown", searchKey: "Dropdown", diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index 744f83f3e3..a95e62d5ce 100644 --- a/app/client/src/constants/WidgetConstants.tsx +++ b/app/client/src/constants/WidgetConstants.tsx @@ -19,6 +19,7 @@ export enum WidgetTypes { CANVAS_WIDGET = "CANVAS_WIDGET", ICON_WIDGET = "ICON_WIDGET", FILE_PICKER_WIDGET = "FILE_PICKER_WIDGET", + VIDEO_WIDGET = "VIDEO_WIDGET", } export type WidgetType = keyof typeof WidgetTypes; diff --git a/app/client/src/constants/messages.ts b/app/client/src/constants/messages.ts index 616a46fc5f..1fa70ebae7 100644 --- a/app/client/src/constants/messages.ts +++ b/app/client/src/constants/messages.ts @@ -13,6 +13,8 @@ export const NAME_SPACE_ERROR = "Name must not have spaces"; export const FORM_VALIDATION_EMPTY_EMAIL = "Please enter an email"; export const FORM_VALIDATION_INVALID_EMAIL = "Please provide a valid email address"; +export const ENTER_VIDEO_URL = "Please provide a valid url"; + export const FORM_VALIDATION_EMPTY_PASSWORD = "Please enter the password"; export const FORM_VALIDATION_PASSWORD_RULE = "Please provide a password with a minimum of 6 characters"; diff --git a/app/client/src/icons/WidgetIcons.tsx b/app/client/src/icons/WidgetIcons.tsx index e4c85b4f71..7eaa30f489 100644 --- a/app/client/src/icons/WidgetIcons.tsx +++ b/app/client/src/icons/WidgetIcons.tsx @@ -6,6 +6,7 @@ import { ReactComponent as CollapseIcon } from "assets/icons/widget/collapse.svg import { ReactComponent as ContainerIcon } from "assets/icons/widget/container.svg"; import { ReactComponent as DatePickerIcon } from "assets/icons/widget/datepicker.svg"; import { ReactComponent as TableIcon } from "assets/icons/widget/table.svg"; +import { ReactComponent as VideoIcon } from "assets/icons/widget/video.svg"; import { ReactComponent as DropDownIcon } from "assets/icons/widget/dropdown.svg"; import { ReactComponent as CheckboxIcon } from "assets/icons/widget/checkbox.svg"; import { ReactComponent as RadioGroupIcon } from "assets/icons/widget/radio.svg"; @@ -60,6 +61,11 @@ export const WidgetIcons: { ), + VIDEO_WIDGET: (props: IconProps) => ( + + + + ), DROP_DOWN_WIDGET: (props: IconProps) => ( diff --git a/app/client/src/mockResponses/PropertyPaneConfigResponse.tsx b/app/client/src/mockResponses/PropertyPaneConfigResponse.tsx index 8e7224261e..b35cf50016 100644 --- a/app/client/src/mockResponses/PropertyPaneConfigResponse.tsx +++ b/app/client/src/mockResponses/PropertyPaneConfigResponse.tsx @@ -119,6 +119,76 @@ const PropertyPaneConfigResponse: PropertyPaneConfigsResponse["data"] = { ], }, ], + VIDEO_WIDGET: [ + { + id: "17.1", + sectionName: "General", + children: [ + { + id: "17.1.1", + propertyName: "url", + label: "Url", + controlType: "INPUT_TEXT", + placeholderText: "Enter url", + inputType: "TEXT", + }, + { + id: "17.1.1", + propertyName: "autoPlay", + label: "autoPlay", + helpText: "Video will be automatically played", + controlType: "SWITCH", + isJSConvertible: true, + }, + { + id: "17.1.2", + helpText: "Controls the visibility of the widget", + propertyName: "isVisible", + label: "Visible", + controlType: "SWITCH", + isJSConvertible: true, + }, + ], + }, + { + id: "17.2", + sectionName: "Actions", + children: [ + { + id: "17.2.1", + helpText: "Triggers an action when the video starts playing", + propertyName: "onStart", + label: "onStart", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + }, + { + id: "17.2.2", + helpText: "Triggers an action when the video ends", + propertyName: "onEnd", + label: "onEnd", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + }, + { + id: "17.2.3", + helpText: "Triggers an action when the video is played", + propertyName: "onPlay", + label: "onPlay", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + }, + { + id: "17.2.4", + helpText: "Triggers an action when the video is paused", + propertyName: "onPause", + label: "onPause", + controlType: "ACTION_SELECTOR", + isJSConvertible: true, + }, + ], + }, + ], TABLE_WIDGET: [ { id: "7.1", diff --git a/app/client/src/mockResponses/WidgetConfigResponse.tsx b/app/client/src/mockResponses/WidgetConfigResponse.tsx index 9ee9d50089..9db7797085 100644 --- a/app/client/src/mockResponses/WidgetConfigResponse.tsx +++ b/app/client/src/mockResponses/WidgetConfigResponse.tsx @@ -92,6 +92,14 @@ const WidgetConfigResponse: WidgetConfigReducerState = { widgetName: "DatePicker", defaultDate: moment().format("DD/MM/YYYY HH:mm"), }, + + VIDEO_WIDGET: { + rows: 7, + columns: 7, + widgetName: "Video", + url: "https://www.youtube.com/watch?v=mzqK0QIZRLs", + autoPlay: false, + }, TABLE_WIDGET: { rows: 7, columns: 8, diff --git a/app/client/src/mockResponses/WidgetSidebarResponse.tsx b/app/client/src/mockResponses/WidgetSidebarResponse.tsx index 6558de5b71..f831424f04 100644 --- a/app/client/src/mockResponses/WidgetSidebarResponse.tsx +++ b/app/client/src/mockResponses/WidgetSidebarResponse.tsx @@ -74,6 +74,11 @@ const WidgetSidebarResponse: { widgetCardName: "Table", key: generateReactKey(), }, + { + type: "VIDEO_WIDGET", + widgetCardName: "Video", + key: generateReactKey(), + }, { type: "MAP_WIDGET", widgetCardName: "Map", diff --git a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx index a75add8504..94a960cf06 100644 --- a/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx +++ b/app/client/src/reducers/entityReducers/widgetConfigReducer.tsx @@ -25,6 +25,7 @@ import { FormButtonWidgetProps } from "widgets/FormButtonWidget"; import { MapWidgetProps } from "widgets/MapWidget"; import { ModalWidgetProps } from "widgets/ModalWidget"; import { IconWidgetProps } from "widgets/IconWidget"; +import { VideoWidgetProps } from "widgets/VideoWidget"; const initialState: WidgetConfigReducerState = WidgetConfigResponse; @@ -57,6 +58,7 @@ export interface WidgetConfigReducerState { WidgetConfigProps; DATE_PICKER_WIDGET: Partial & WidgetConfigProps; TABLE_WIDGET: Partial & WidgetConfigProps; + VIDEO_WIDGET: Partial & WidgetConfigProps; DROP_DOWN_WIDGET: Partial & WidgetConfigProps; CHECKBOX_WIDGET: Partial & WidgetConfigProps; RADIO_GROUP_WIDGET: Partial & WidgetConfigProps; diff --git a/app/client/src/utils/WidgetRegistry.tsx b/app/client/src/utils/WidgetRegistry.tsx index afa52083d3..ef15c269c8 100644 --- a/app/client/src/utils/WidgetRegistry.tsx +++ b/app/client/src/utils/WidgetRegistry.tsx @@ -38,6 +38,10 @@ import TableWidget, { TableWidgetProps, ProfiledTableWidget, } from "widgets/TableWidget"; +import VideoWidget, { + VideoWidgetProps, + ProfiledVideoWidget, +} from "widgets/VideoWidget"; import TabsWidget, { TabsWidgetProps, TabContainerWidgetProps, @@ -204,6 +208,21 @@ export default class WidgetBuilderRegistry { TableWidget.getDefaultPropertiesMap(), TableWidget.getMetaPropertiesMap(), ); + + WidgetFactory.registerWidgetBuilder( + "VIDEO_WIDGET", + { + buildWidget(widgetData: VideoWidgetProps): JSX.Element { + return ; + }, + }, + VideoWidget.getPropertyValidationMap(), + VideoWidget.getDerivedPropertiesMap(), + VideoWidget.getTriggerPropertyMap(), + VideoWidget.getDefaultPropertiesMap(), + VideoWidget.getMetaPropertiesMap(), + ); + WidgetFactory.registerWidgetBuilder( "FILE_PICKER_WIDGET", { diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index 619a96907b..aae763d7f5 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -67,6 +67,13 @@ export const entityDefinitions = { isVisible: isVisible, searchText: "string", }), + VIDEO_WIDGET: (widget: any) => ({ + "!doc": + "Video widget can be used for playing a variety of URLs, including file paths, YouTube, Facebook, Twitch, SoundCloud, Streamable, Vimeo, Wistia, Mixcloud, and DailyMotion.", + "!url": "https://docs.appsmith.com/widget-reference/video", + playState: "number", + autoPlay: "bool", + }), DROP_DOWN_WIDGET: { "!doc": "Dropdown is used to capture user input/s from a specified list of permitted inputs. A Dropdown can capture a single choice as well as multiple choices", diff --git a/app/client/src/widgets/VideoWidget.tsx b/app/client/src/widgets/VideoWidget.tsx new file mode 100644 index 0000000000..ec3b8aebea --- /dev/null +++ b/app/client/src/widgets/VideoWidget.tsx @@ -0,0 +1,135 @@ +import React, { Suspense, lazy } from "react"; +import BaseWidget, { WidgetProps, WidgetState } from "./BaseWidget"; +import { WidgetType } from "constants/WidgetConstants"; +import { EventType } from "constants/ActionConstants"; +import { VALIDATION_TYPES } from "constants/WidgetValidation"; +import { + WidgetPropertyValidationType, + BASE_WIDGET_VALIDATION, +} from "utils/ValidationFactory"; +import { TriggerPropertiesMap } from "utils/WidgetFactory"; +import Skeleton from "components/utils/Skeleton"; +import * as Sentry from "@sentry/react"; +import { retryPromise } from "utils/AppsmithUtils"; +import ReactPlayer from "react-player"; + +const VideoComponent = lazy(() => + retryPromise(() => + import("components/designSystems/appsmith/VideoComponent"), + ), +); + +export enum PlayState { + NOT_STARTED = "NOT_STARTED", + PAUSED = "PAUSED", + ENDED = "ENDED", + PLAYING = "PLAYING", +} + +class VideoWidget extends BaseWidget { + private _player = React.createRef(); + static getPropertyValidationMap(): WidgetPropertyValidationType { + return { + ...BASE_WIDGET_VALIDATION, + url: VALIDATION_TYPES.TEXT, + }; + } + + static getMetaPropertiesMap(): Record { + return { + playState: PlayState.NOT_STARTED, + }; + } + + static getDefaultPropertiesMap(): Record { + return {}; + } + + static getTriggerPropertyMap(): TriggerPropertiesMap { + return { + onStart: true, + onEnd: true, + onPlay: true, + onPause: true, + }; + } + + shouldComponentUpdate(nextProps: VideoWidgetProps) { + return nextProps.url !== this.props.url; + } + + getPageView() { + const { url, autoPlay, onStart, onEnd, onPause, onPlay } = this.props; + return ( + }> + { + this.updateWidgetMetaProperty("playState", PlayState.PLAYING); + if (onStart) { + super.executeAction({ + dynamicString: onStart, + event: { + type: EventType.ON_VIDEO_START, + }, + }); + } + }} + onPlay={() => { + this.updateWidgetMetaProperty("playState", PlayState.PLAYING); + if (onPlay) { + super.executeAction({ + dynamicString: onPlay, + event: { + type: EventType.ON_VIDEO_PLAY, + }, + }); + } + }} + onPause={() => { + //TODO: We do not want the pause event for onSeek or onEnd. + this.updateWidgetMetaProperty("playState", PlayState.PAUSED); + if (onPause) { + super.executeAction({ + dynamicString: onPause, + event: { + type: EventType.ON_VIDEO_PAUSE, + }, + }); + } + }} + onEnded={() => { + this.updateWidgetMetaProperty("playState", PlayState.ENDED); + if (onEnd) { + super.executeAction({ + dynamicString: onEnd, + event: { + type: EventType.ON_VIDEO_END, + }, + }); + } + }} + /> + + ); + } + + getWidgetType(): WidgetType { + return "VIDEO_WIDGET"; + } +} + +export interface VideoWidgetProps extends WidgetProps { + url: string; + autoPlay: boolean; + onStart?: string; + onPause?: string; + onPlay?: string; + onEnd?: string; +} + +export default VideoWidget; +export const ProfiledVideoWidget = Sentry.withProfiler(VideoWidget);