Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/client/src/components/contribute/TagForm.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<any>>;
}

const renderers = [...materialRenderers, { tester: AslLexSearchControlTester, renderer: AslLexSearchControl }];

export const TagForm: React.FC<TagFormProps> = (props) => {
const [data, setData] = useState<any>();
const [dataValid, setDataValid] = useState<boolean>(false);
Expand Down Expand Up @@ -44,6 +44,12 @@ export const TagForm: React.FC<TagFormProps> = (props) => {
setData({});
};

const renderers: JsonFormsRendererRegistryEntry[] = [
...materialRenderers,
{ tester: videoFieldTester, renderer: VideoRecordField },
{ tester: AslLexSearchControlTester, renderer: AslLexSearchControl }
];

return (
<Box sx={{ maxWidth: 500 }}>
<Stack direction="column" spacing={2}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,8 +20,6 @@ const AslLexSearchControl = ({ handleChange, path }: AslLexSearchControlProps) =
const [searchResults, setSearchResults] = useState<LexiconEntry[]>([]);
const [value, setValue] = useState<LexiconEntry | null>(null);

console.log('searchre', searchResults);

return (
<>
<TextSearch width={300} lexicon={aslLexicon} setSearchResults={setSearchResults} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StatusProcessCirclesProps> = (props) => {
return (
<Stack direction="row" spacing={2}>
{props.isComplete.map((isComplete, index) => (
<StatusProcessCircle
key={index}
isComplete={isComplete}
onClick={() => props.setState(index)}
activeIndex={props.activeIndex}
index={index}
/>
))}
</Stack>
);
};

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<StatusProcessCircleProps> = (props) => {
const circleSize = '50px';

return (
<Box
sx={{
width: circleSize,
height: circleSize,
borderRadius: '50%',
backgroundColor: props.isComplete ? 'success.main' : '',
cursor: 'pointer',
border: '4px solid',
borderColor: props.activeIndex === props.index ? 'primary.main' : 'text.secondary'
}}
onClick={props.onClick}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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<ControlProps> = (props) => {
const [maxVideos, setMaxVideos] = useState<number>(0);
const [minimumVideos, setMinimumVideos] = useState<number>(0);
const [validVideos, setValidVideos] = useState<boolean[]>([]);
const [activeIndex, setActiveIndex] = useState<number>(0);
const [blobs, setBlobs] = useState<(Blob | null)[]>([]);
const [recording, setRecording] = useState<boolean>(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 (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography sx={{ width: '33%' }}>{props.label}</Typography>
<Typography>{props.description}</Typography>
</AccordionSummary>
<AccordionDetails>
<Stack direction="column" spacing={2} sx={{ width: '50%', margin: 'auto' }}>
<Typography variant="h5">
Required: {minimumVideos}, Optional Max: {maxVideos}
</Typography>
<StatusProcessCircles isComplete={validVideos} setState={() => {}} activeIndex={activeIndex} />

<Stack direction="row" spacing={2} sx={{ justifyContent: 'center' }}>
{/* Left navigation button */}
<IconButton size="large" disabled={activeIndex == 0} onClick={() => setActiveIndex(activeIndex - 1)}>
<ArrowLeft fontSize="large" />
</IconButton>

<VideoRecordInterface
activeBlob={blobs[activeIndex]}
recordVideo={(blob) => handleVideoRecord(blob)}
recording={recording}
/>

{/* Right navigation button */}
<IconButton
size="large"
disabled={activeIndex == validVideos.length - 1 || validVideos[activeIndex] === false}
onClick={() => setActiveIndex(activeIndex + 1)}
>
<ArrowRight fontSize="large" />
</IconButton>
</Stack>
<Button variant={recording ? 'contained' : 'outlined'} onClick={() => setRecording(!recording)}>
{recording ? 'Stop' : 'Start'} Recording
</Button>
</Stack>
</AccordionDetails>
</Accordion>
);
};

export const videoFieldTester: RankedTester = rankWith(10, (uischema, _schema, _rootSchema) => {
return uischema.options != undefined && uischema.options && uischema.options.customType === 'video';
});

export default withJsonFormsControlProps(VideoRecordField);
Original file line number Diff line number Diff line change
@@ -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<VideoRecordInterfaceProps> = (props) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [blobs, setBlobs] = useState<Blob[]>([]);
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 (
<>
<video style={{ minWidth: 500 }} ref={videoRef} controls />
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Box, Button, Container, Dialog, DialogActions, DialogContent, Typograph
import { useState } from 'react';
import { TagSchema } from '../../graphql/graphql';
import { materialRenderers } from '@jsonforms/material-renderers';
import AslLexSearchControl from '../../jsonForms/customRenderes/AslLexSearchControl';
import AslLexSearchControlTester from '../../jsonForms/customRenderes/aslLexSearchControlTester';
import AslLexSearchControl from '../tag/asllex/AslLexSearchControl';
import AslLexSearchControlTester from '../tag/asllex/aslLexSearchControlTester';

interface DialogProps {
schema: TagSchema;
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/pages/contribute/TaggingInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ interface MainViewProps {

const MainView: React.FC<MainViewProps> = (props) => {
return (
<Box sx={{ justifyContent: 'space-between', display: 'flex', maxWidth: 1000, margin: 'auto' }}>
<Box sx={{ justifyContent: 'space-between', display: 'flex', maxWidth: '80%', margin: 'auto' }}>
<EntryView
entry={props.tag.entry}
width={500}
Expand Down