diff --git a/packages/client/src/components/contribute/TagForm.component.tsx b/packages/client/src/components/contribute/TagForm.component.tsx index 0361f8da..f4c62151 100644 --- a/packages/client/src/components/contribute/TagForm.component.tsx +++ b/packages/client/src/components/contribute/TagForm.component.tsx @@ -4,16 +4,16 @@ import { materialRenderers } from '@jsonforms/material-renderers'; import { SetStateAction, useState, Dispatch } from 'react'; import { Box, Stack, Button } from '@mui/material'; import { ErrorObject } from 'ajv'; -import AslLexSearchControl from '../../jsonForms/customRenderes/AslLexSearchControl'; -import AslLexSearchControlTester from '../../jsonForms/customRenderes/aslLexSearchControlTester'; +import AslLexSearchControl from '../tag/asllex/AslLexSearchControl'; +import AslLexSearchControlTester from '../tag/asllex/aslLexSearchControlTester'; +import VideoRecordField, { videoFieldTester } from '../tag/videorecord/VideoRecordField.component'; +import { JsonFormsRendererRegistryEntry } from '@jsonforms/core'; export interface TagFormProps { study: Study; setTagData: Dispatch>; } -const renderers = [...materialRenderers, { tester: AslLexSearchControlTester, renderer: AslLexSearchControl }]; - export const TagForm: React.FC = (props) => { const [data, setData] = useState(); const [dataValid, setDataValid] = useState(false); @@ -44,6 +44,12 @@ export const TagForm: React.FC = (props) => { setData({}); }; + const renderers: JsonFormsRendererRegistryEntry[] = [ + ...materialRenderers, + { tester: videoFieldTester, renderer: VideoRecordField }, + { tester: AslLexSearchControlTester, renderer: AslLexSearchControl } + ]; + return ( diff --git a/packages/client/src/jsonForms/customRenderes/AslLexSearchControl.tsx b/packages/client/src/components/tag/asllex/AslLexSearchControl.tsx similarity index 92% rename from packages/client/src/jsonForms/customRenderes/AslLexSearchControl.tsx rename to packages/client/src/components/tag/asllex/AslLexSearchControl.tsx index 2a5eeb9e..f736f7b5 100644 --- a/packages/client/src/jsonForms/customRenderes/AslLexSearchControl.tsx +++ b/packages/client/src/components/tag/asllex/AslLexSearchControl.tsx @@ -1,7 +1,7 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { TextSearch, SearchResults } from '@bu-sail/saas-view'; import { useState } from 'react'; -import { LexiconEntry } from '../../graphql/graphql'; +import { LexiconEntry } from '../../../graphql/graphql'; interface AslLexSearchControlProps { data: any; @@ -20,8 +20,6 @@ const AslLexSearchControl = ({ handleChange, path }: AslLexSearchControlProps) = const [searchResults, setSearchResults] = useState([]); const [value, setValue] = useState(null); - console.log('searchre', searchResults); - return ( <> diff --git a/packages/client/src/jsonForms/customRenderes/aslLexSearchControlTester.ts b/packages/client/src/components/tag/asllex/aslLexSearchControlTester.ts similarity index 100% rename from packages/client/src/jsonForms/customRenderes/aslLexSearchControlTester.ts rename to packages/client/src/components/tag/asllex/aslLexSearchControlTester.ts diff --git a/packages/client/src/components/tag/videorecord/StatusCircles.component.tsx b/packages/client/src/components/tag/videorecord/StatusCircles.component.tsx new file mode 100644 index 00000000..9005a50d --- /dev/null +++ b/packages/client/src/components/tag/videorecord/StatusCircles.component.tsx @@ -0,0 +1,56 @@ +import { Box, Stack } from '@mui/material'; + +export interface StatusProcessCirclesProps { + /** List of statuses to display */ + isComplete: boolean[]; + /** Handle when a user clicks a circle */ + setState: (index: number) => void; + /** The active index */ + activeIndex: number; +} + +export const StatusProcessCircles: React.FC = (props) => { + return ( + + {props.isComplete.map((isComplete, index) => ( + props.setState(index)} + activeIndex={props.activeIndex} + index={index} + /> + ))} + + ); +}; + +interface StatusProcessCircleProps { + /** Whether the circle is complete */ + isComplete: boolean; + /** Handle when a user clicks a circle */ + onClick: () => void; + /** The active index */ + activeIndex: number; + /** The index of the circle */ + index: number; +} + +const StatusProcessCircle: React.FC = (props) => { + const circleSize = '50px'; + + return ( + + ); +}; diff --git a/packages/client/src/components/tag/videorecord/VideoRecordField.component.tsx b/packages/client/src/components/tag/videorecord/VideoRecordField.component.tsx new file mode 100644 index 00000000..95bab254 --- /dev/null +++ b/packages/client/src/components/tag/videorecord/VideoRecordField.component.tsx @@ -0,0 +1,102 @@ +import { ControlProps, RankedTester, rankWith } from '@jsonforms/core'; +import { ArrowLeft, ArrowRight, ExpandMore } from '@mui/icons-material'; +import { Accordion, AccordionDetails, AccordionSummary, Typography, Stack, Button, IconButton } from '@mui/material'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { StatusProcessCircles } from './StatusCircles.component'; +import { VideoRecordInterface } from './VideoRecordInterface.component'; +import { useEffect, useState, useRef } from 'react'; + +const VideoRecordField: React.FC = (props) => { + const [maxVideos, setMaxVideos] = useState(0); + const [minimumVideos, setMinimumVideos] = useState(0); + const [validVideos, setValidVideos] = useState([]); + const [activeIndex, setActiveIndex] = useState(0); + const [blobs, setBlobs] = useState<(Blob | null)[]>([]); + const [recording, setRecording] = useState(false); + const stateRef = useRef<{ validVideos: boolean[]; blobs: (Blob | null)[]; activeIndex: number }>(); + stateRef.current = { validVideos, blobs, activeIndex }; + + useEffect(() => { + if (!props.uischema.options?.minimumRequired) { + console.error('Minimum number of videos required not specified'); + return; + } + const minimumVideos = props.uischema.options.minimumRequired; + + let maxVideos = minimumVideos; + if (props.uischema.options?.maximumOptional) { + maxVideos = props.uischema.options.maximumOptional; + } + + setValidVideos(Array.from({ length: maxVideos }, (_, _i) => false)); + setMinimumVideos(minimumVideos); + setMaxVideos(maxVideos); + setBlobs(Array.from({ length: maxVideos }, (_, _i) => null)); + }, [props.uischema]); + + const handleVideoRecord = (video: Blob | null) => { + const updatedBlobs = stateRef.current!.blobs.map((blob, index) => { + if (index === stateRef.current!.activeIndex) { + return video; + } + return blob; + }); + const updateValidVideos = stateRef.current!.validVideos.map((valid, index) => { + if (index === stateRef.current!.activeIndex) { + return video !== null; + } + return valid; + }); + + setBlobs(updatedBlobs); + setValidVideos(updateValidVideos); + }; + + return ( + + }> + {props.label} + {props.description} + + + + + Required: {minimumVideos}, Optional Max: {maxVideos} + + {}} activeIndex={activeIndex} /> + + + {/* Left navigation button */} + setActiveIndex(activeIndex - 1)}> + + + + handleVideoRecord(blob)} + recording={recording} + /> + + {/* Right navigation button */} + setActiveIndex(activeIndex + 1)} + > + + + + + + + + ); +}; + +export const videoFieldTester: RankedTester = rankWith(10, (uischema, _schema, _rootSchema) => { + return uischema.options != undefined && uischema.options && uischema.options.customType === 'video'; +}); + +export default withJsonFormsControlProps(VideoRecordField); diff --git a/packages/client/src/components/tag/videorecord/VideoRecordInterface.component.tsx b/packages/client/src/components/tag/videorecord/VideoRecordInterface.component.tsx new file mode 100644 index 00000000..26a4a6f3 --- /dev/null +++ b/packages/client/src/components/tag/videorecord/VideoRecordInterface.component.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +export interface VideoRecordInterfaceProps { + activeBlob: Blob | null; + recordVideo: (blob: Blob | null) => void; + recording: boolean; +} + +export const VideoRecordInterface: React.FC = (props) => { + const videoRef = useRef(null); + const [mediaRecorder, setMediaRecorder] = useState(null); + const [blobs, setBlobs] = useState([]); + const stateRef = useRef<{ blobs: Blob[] }>(); + stateRef.current = { blobs }; + + // On data available, store the blob + const handleOnDataAvailable = useCallback( + (event: BlobEvent) => { + const newBlobs = [...stateRef.current!.blobs, event.data]; + setBlobs(newBlobs); + + // If the recording is complete, send the blob to the parent + if (!props.recording) { + props.recordVideo(new Blob(newBlobs, { type: 'video/webm' })); + } + }, + [setBlobs, blobs] + ); + + const startRecording = async () => { + // Clear the blobs + setBlobs([]); + + // Create the media recorder + // TODO: In the future have audio be an option + const stream: MediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + + // Setup the preview + videoRef.current!.srcObject = stream; + videoRef.current!.play(); + + // Set the encoding + const options = { mimeType: 'video/webm; codecs=vp9' }; + + // Create the media recorder + let mediaRecorder = new MediaRecorder(stream, options); + + mediaRecorder.ondataavailable = handleOnDataAvailable; + + // Start recording + mediaRecorder.start(); + setMediaRecorder(mediaRecorder); + }; + + const stopRecording = () => { + if (mediaRecorder) { + mediaRecorder.stop(); + } + }; + + // Handle changes to the recording status + useEffect(() => { + if (props.recording) { + startRecording(); + } else { + stopRecording(); + } + }, [props.recording]); + + // Control the display based on if an active blob is present + useEffect(() => { + // If there is no active blob, show the video preview + if (!props.activeBlob) { + videoRef.current!.style.display = 'block'; + videoRef.current!.src = ''; + return; + } + + // Otherwise show the recording blobl + const blobUrl = URL.createObjectURL(props.activeBlob); + videoRef.current!.srcObject = null; + videoRef.current!.src = blobUrl; + }, [props.activeBlob]); + + return ( + <> +