Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add audio recorder item control component #403

Merged
merged 3 commits into from
Dec 10, 2024
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
19 changes: 19 additions & 0 deletions src/components/AudioRecorder/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// eslint-disable-next-line
import { useAudioRecorder as useAudioRecorderControl } from "react-audio-voice-recorder";

export interface RecorderControls {
startRecording: () => void;
stopRecording: () => void;
togglePauseResume: () => void;
recordingBlob?: Blob;
isRecording: boolean;
isPaused: boolean;
recordingTime: number;
mediaRecorder?: MediaRecorder;
}

export function useAudioRecorder() {
const recorderControls: RecorderControls = useAudioRecorderControl();

return { recorderControls };
}
73 changes: 73 additions & 0 deletions src/components/AudioRecorder/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Trans } from '@lingui/macro';
// eslint-disable-next-line
import { AudioRecorder as AudioRecorderControl } from 'react-audio-voice-recorder';

import { RecorderControls } from './hooks';
import { S } from './styles';
import { uuid4 } from '@beda.software/fhir-react';
import { Upload, type UploadFile } from 'antd';
import React from 'react';
import { RcFile } from 'antd/lib/upload/interface';

interface AudioRecorderProps {
onChange: (url: RcFile) => Promise<void>;
recorderControls: RecorderControls;
}

export function AudioRecorder(props: AudioRecorderProps) {
const { recorderControls, onChange } = props;

const onRecordingComplete = async (blob: Blob) => {
const uuid = uuid4();
const audioFile = new File([blob], `${uuid}.webm`, { type: blob.type }) as RcFile;
audioFile.uid = uuid;
onChange(audioFile);
};

return (
<S.Scriber>
<S.Title $danger>
<Trans>Capture in progress</Trans>
</S.Title>
<AudioRecorderControl
showVisualizer
onRecordingComplete={onRecordingComplete}
audioTrackConstraints={{
noiseSuppression: true,
echoCancellation: true,
}}
recorderControls={{
...recorderControls,
}}
/>
</S.Scriber>
);
}

interface AudioPlayerProps {
files: UploadFile[];
onRemove?: (file: UploadFile) => void;
}

export function AudioPlayer(props: AudioPlayerProps) {
const { files, onRemove } = props;

return (
<S.Scriber>
<S.Title>
<Trans>Listen to the audio</Trans>
</S.Title>
{files.map((file) => (
<React.Fragment key={file.uid}>
<S.Audio controls src={file.url} />
<Upload
listType="text"
showUploadList={{ showRemoveIcon: !!onRemove }}
fileList={[file]}
onRemove={() => onRemove?.(file)}
/>
</React.Fragment>
))}
</S.Scriber>
);
}
50 changes: 50 additions & 0 deletions src/components/AudioRecorder/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import styled, { css } from 'styled-components';

import { Text } from 'src/components/Typography';

export const S = {
Scriber: styled.div`
display: flex;
flex-direction: column;
gap: 8px 0;

.audio-recorder {
width: 100%;
box-shadow: none;
border-radius: 30px;
background-color: ${({ theme }) => theme.neutralPalette.gray_2};
padding: 3px 6px 3px 18px;
}

.audio-recorder-timer,
.audio-recorder-status {
font-family: inherit;
color: ${({ theme }) => theme.neutralPalette.gray_12};
}

.audio-recorder-mic {
display: none;
}

.audio-recorder-timer {
margin-left: 0;
}

.audio-recorder-options {
filter: ${({ theme }) => (theme.mode === 'dark' ? `invert(100%)` : `invert(0%)`)};
}
`,
Title: styled(Text)<{ $danger?: boolean }>`
font-weight: 700;

${({ $danger }) =>
$danger &&
css`
color: ${({ theme }) => theme.antdTheme?.red5};
`}
`,
Audio: styled.audio`
height: 52px;
width: 100%;
`,
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { QuestionReference } from './readonly-widgets/reference';
import { AnxietyScore, DepressionScore } from './readonly-widgets/score';
import { QuestionText, TextWithInput } from './readonly-widgets/string';
import { TimeRangePickerControl } from './readonly-widgets/TimeRangePickerControl';
import { UploadFileControlReadOnly } from './widgets/UploadFileControl';
import { UploadFile } from './readonly-widgets/UploadFile';
import { AudioAttachment } from './readonly-widgets/AudioAttachment';

interface Props extends Partial<QRFContextData> {
formData: QuestionnaireResponseFormData;
Expand Down Expand Up @@ -66,14 +67,15 @@ export function ReadonlyQuestionnaireResponseForm(props: Props) {
reference: QuestionReference,
display: Display,
boolean: QuestionBoolean,
attachment: UploadFileControlReadOnly,
attachment: UploadFile,
...questionItemComponents,
}}
itemControlQuestionItemComponents={{
'inline-choice': QuestionChoice,
'anxiety-score': AnxietyScore,
'depression-score': DepressionScore,
'input-inside-text': TextWithInput,
'audio-recorder-uploader': AudioAttachment,
...itemControlQuestionItemComponents,
}}
>
Expand Down
2 changes: 2 additions & 0 deletions src/components/BaseQuestionnaireResponseForm/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { QuestionReference } from './widgets/reference';
import { ReferenceRadioButton } from './widgets/ReferenceRadioButton';
import { UploadFileControl } from './widgets/UploadFileControl';
import { TextWithMacroFill } from '../TextWithMacroFill';
import { AudioRecorderUploader } from './widgets/AudioRecorderUploader';

export const itemComponents: QuestionItemComponentMapping = {
text: QuestionText,
Expand Down Expand Up @@ -66,6 +67,7 @@ export const itemControlComponents: ItemControlQuestionItemComponentMapping = {
'check-box': InlineChoice,
'input-inside-text': QuestionInputInsideText,
'markdown-editor': MDEditorControl,
'audio-recorder-uploader': AudioRecorderUploader,
};

export const groupControlComponents: ItemControlGroupItemComponentMapping = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Upload } from 'antd';
import { QuestionItemProps } from 'sdc-qrf';
import classNames from 'classnames';

import s from './ReadonlyWidgets.module.scss';
import { S } from './ReadonlyWidgets.styles';
import { useUploader } from '../widgets/UploadFileControl/hooks';
import React from 'react';

export function AudioAttachment(props: QuestionItemProps) {
const { questionItem } = props;
const { text, hidden } = questionItem;
const { fileList } = useUploader(props);

if (hidden) {
return null;
}

return (
<S.Question className={classNames(s.question, s.column, 'form__question')}>
<span className={s.questionText}>{text}</span>
{fileList.length ? (
fileList.map((file) => (
<React.Fragment key={file.url}>
<S.Audio controls src={file.url} />
<Upload listType="text" showUploadList={{ showRemoveIcon: false }} fileList={fileList} />
</React.Fragment>
))
) : (
<span>-</span>
)}
</S.Question>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ export const S = {
border-top: 1px solid ${({ theme }) => theme.neutralPalette.gray_4};
}
`,
Audio: styled.audio`
height: 52px;
width: 100%;
`,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Upload } from 'antd';
import { QuestionItemProps } from 'sdc-qrf';
import classNames from 'classnames';

import s from './ReadonlyWidgets.module.scss';
import { S } from './ReadonlyWidgets.styles';
import { useUploader } from '../widgets/UploadFileControl/hooks';

export function UploadFile(props: QuestionItemProps) {
const { questionItem } = props;
const { text, hidden } = questionItem;
const { fileList } = useUploader(props);

if (hidden) {
return null;
}

return (
<S.Question className={classNames(s.question, s.column, 'form__question')}>
<span className={s.questionText}>{text}</span>
{fileList.length ? (
<Upload listType="picture" showUploadList={{ showRemoveIcon: false }} fileList={fileList} />
) : (
<span>-</span>
)}
</S.Question>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { AudioOutlined } from '@ant-design/icons';
import { Trans } from '@lingui/macro';
import { Form, UploadFile } from 'antd';
import { useCallback, useState } from 'react';
import { QuestionItemProps } from 'sdc-qrf';

import { AudioPlayer as AudioPlayerControl, AudioRecorder as AudioRecorderControl } from 'src/components/AudioRecorder';
import { useAudioRecorder } from 'src/components/AudioRecorder/hooks';

import { useUploader } from '../UploadFileControl/hooks';
import { RcFile } from 'antd/lib/upload/interface';
import { isSuccess } from '@beda.software/remote-data';
import { S } from './styles';
import { UploadFileControl } from '../UploadFileControl';

export function AudioRecorderUploader(props: QuestionItemProps) {
const { questionItem } = props;
const [showScriber, setShowScriber] = useState(false);

const { recorderControls } = useAudioRecorder();
const { formItem, customRequest, onChange, fileList } = useUploader(props);
const hasFiles = fileList.length > 0;

const onScribeChange = useCallback(
async (file: RcFile) => {
setShowScriber(false);

const fileClone = new File([file], file.name, {
type: file.type,
}) as any as UploadFile;
fileClone.uid = file.uid;
fileClone.status = 'uploading';
fileClone.percent = 0;

onChange({
fileList: [...fileList, fileClone],
file: fileClone,
});

const response = await customRequest({ file });

if (isSuccess(response)) {
fileClone.status = 'done';
fileClone.url = response.data.uploadUrl;
fileClone.percent = 100;

onChange({
fileList: [...fileList, fileClone],
file: fileClone,
});
}
},
[fileList],
);

const renderContent = () => {
if (hasFiles) {
return (
<S.Container>
<AudioPlayerControl files={fileList} />
</S.Container>
);
}

if (showScriber) {
return (
<S.Container>
<AudioRecorderControl recorderControls={recorderControls} onChange={onScribeChange} />
</S.Container>
);
}

return (
<>
<S.Button
icon={<AudioOutlined />}
type="primary"
onClick={() => {
setShowScriber(true);
recorderControls.startRecording();
}}
>
<span>
<Trans>Start scribe</Trans>
</span>
</S.Button>
<UploadFileControl
{...props}
questionItem={{
...questionItem,
text: undefined,
}}
/>
</>
);
};

return <Form.Item {...formItem}>{renderContent()}</Form.Item>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Button } from 'antd';
import styled from 'styled-components';

export const S = {
Container: styled.div`
border-radius: 10px;
padding: 12px 8px;
border: 1px solid ${({ theme }) => theme.neutralPalette.gray_4};
`,
Button: styled(Button)`
width: 100%;
`,
};
Loading
Loading