Skip to content

Add MediaRecorder component #244

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
18 changes: 18 additions & 0 deletions src/npm-fastui-bootstrap/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ export const classNameGenerator: ClassNameGenerator = ({
'btn-secondary': props.namedStyle === 'secondary',
'btn-warning': props.namedStyle === 'warning',
}
case 'Recorder':
switch (subElement) {
case 'left-image':
return { 'me-1': !props.hideText, 'ms-0': true, 'align-middle': true }
case 'right-image':
return { 'ms-1': !props.hideText, 'me-0': true, 'align-middle': true }
case 'container':
return { 'd-flex': true, 'gap-1': props.displayStyle !== 'toggle' }
}
return {
btn: true,
'btn-primary': !props.namedStyle || props.namedStyle === 'primary',
'btn-secondary': props.namedStyle === 'secondary',
'btn-warning': props.namedStyle === 'warning',
'd-flex': true,
'flex-row': props.imagePosition === 'left' && props.displayStyle !== 'toggle',
'flex-row-reverse': props.imagePosition === 'right' && props.displayStyle !== 'toggle',
}
case 'Table':
switch (subElement) {
case 'no-data-message':
Expand Down
211 changes: 211 additions & 0 deletions src/npm-fastui/src/components/MediaRecorder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { useEffect, useState, useMemo, type FC, type MouseEventHandler, useRef } from 'react'

import type { Recorder } from '../models'

import { useClassName } from '../hooks/className'

export const RecorderComp: FC<Recorder> = (props) => {
const {
audioConstraints = true,
videoConstraints = false,
peerIdentity,
preferCurrentTab,
options,
submitUrl,
saveRecording,
hideText = false,
text = 'Start Recording',
stopText = 'Stop Recording',
hideImage = false,
imageUrl,
stopImageUrl,
imagePosition = 'left',
imageWidth = '24px',
imageHeight = '24px',
displayStyle = 'standard',
overrideFieldName = 'recording',
} = props
const [recordingSubmitUrl, setRecordingSubmitUrl] = useState(submitUrl)
const [saveRecordings, setSaveRecordings] = useState(saveRecording)
const [buttonStartText, setButtonStartText] = useState(text)
const [buttonStopText, setButtonStopText] = useState(stopText)
const [buttonTextVisible, setButtonTextVisible] = useState(!hideText)
const [buttonStartImageUrl, setButtonStartImageUrl] = useState(imageUrl)
const [buttonStopImageUrl, setButtonStopImageUrl] = useState(stopImageUrl ?? imageUrl)
const [buttonImageVisible, setButtonImageVisible] = useState(!hideImage)
const [buttonImageWidth, setButtonImageWidth] = useState(imageWidth)
const [buttonImageHeight, setButtonImageHeight] = useState(imageHeight)
const [buttonDisplayStyle, setButtonDisplayStyle] = useState(displayStyle)
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [recordingFieldName, setRecordingFieldName] = useState(overrideFieldName)

useEffect(() => {
setRecordingSubmitUrl(submitUrl)
setSaveRecordings(saveRecording)
setButtonTextVisible(!hideText)
setButtonStartText(hideText ? '' : text)
setButtonStopText(hideText ? '' : stopText)
setButtonImageVisible(!hideImage)
setButtonStartImageUrl(hideImage ? '' : imageUrl)
setButtonStopImageUrl(hideImage ? '' : stopImageUrl ?? imageUrl)
setButtonImageWidth(imageWidth)
setButtonImageHeight(imageHeight)
setButtonDisplayStyle(displayStyle)
setRecordingFieldName(overrideFieldName)
}, [
submitUrl,
saveRecording,
hideText,
text,
stopText,
hideImage,
imageUrl,
stopImageUrl,
imageWidth,
imageHeight,
displayStyle,
overrideFieldName,
])

const handleDownloadRecording = (blob: Blob): void => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = 'recording.webm'
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
}

const mediaStreamConstraints: MediaStreamConstraints = useMemo(
() => ({
audio: audioConstraints ?? true,
video: videoConstraints ?? false,
peerIdentity,
preferCurrentTab,
}),
[audioConstraints, videoConstraints, peerIdentity, preferCurrentTab],
)

useEffect(() => {
mediaRecorderRef.current = mediaRecorder
}, [mediaRecorder])

useEffect(() => {
// initialize media recording
const initMediaRecorder = async (): Promise<void> => {
try {
const stream = await navigator.mediaDevices.getUserMedia(mediaStreamConstraints)
const recorder = new MediaRecorder(stream, options)

// get recorded data
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
const blobRecording = new Blob([event.data], { type: event.data.type })

if (saveRecordings) {
console.log('Saving recording')
handleDownloadRecording(blobRecording)
}
if (recordingSubmitUrl) {
const formData = new FormData()
formData.append(recordingFieldName, blobRecording)
const response = await fetch(recordingSubmitUrl, {
method: 'POST',
body: formData,
})
return response
}
}
}
setMediaRecorder(recorder)
} catch (error) {
console.error('Error initializing media recorder', error)
}
}

initMediaRecorder()

return () => mediaRecorderRef.current?.stream?.getTracks?.()?.forEach((track) => track.stop())
}, [options, saveRecordings, recordingFieldName, recordingSubmitUrl, mediaStreamConstraints])

const handleStartRecording = (): void => {
if (!mediaRecorder) {
console.error('Media recorder not initialized')
return
}
setIsRecording(true)
mediaRecorder.start()
console.log('Recording started')
}

const handleStopRecording = (): void => {
if (!mediaRecorder) {
console.error('Media recorder not initialized')
return
}
mediaRecorder.stop()
setIsRecording(false)
console.log('Recording stopped')
}

const handleOnClick: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
isRecording ? handleStopRecording() : handleStartRecording()
}

const ImageButton: FC<{
text?: string
imageUrl?: string
disabled?: boolean
}> = ({ text, imageUrl, disabled = false }) => {
const leftImgClassName = useClassName(props, { el: 'left-image' })
const rightImgClassName = useClassName(props, { el: 'right-image' })
const imgClassName = imagePosition === 'left' ? leftImgClassName : rightImgClassName

return (
<button
className={useClassName(props)}
onClick={handleOnClick}
disabled={disabled}
aria-label={text}
aria-disabled={disabled}
>
{buttonImageVisible && imageUrl && (
<img
className={imgClassName}
src={imageUrl}
alt={`${text}${disabled ? ' disabled' : ''} button image`}
style={{
width: buttonImageWidth,
height: buttonImageHeight,
}}
/>
)}
{buttonTextVisible && text}
</button>
)
}

const StandardButtons: FC = (): JSX.Element => (
<>
<ImageButton text={buttonStartText} imageUrl={buttonStartImageUrl} disabled={isRecording} />
<ImageButton text={buttonStopText} imageUrl={buttonStopImageUrl} disabled={!isRecording} />
</>
)

const ToggleButton: FC = (): JSX.Element => {
const displayText = () => (isRecording ? buttonStopText : buttonStartText)
const displayImageUrl = () => (isRecording ? buttonStopImageUrl : buttonStartImageUrl)
return <ImageButton text={displayText()} imageUrl={displayImageUrl()} />
}

return (
<div className={useClassName(props, { el: 'container' })}>
{buttonDisplayStyle === 'toggle' ? <ToggleButton /> : <StandardButtons />}
</div>
)
}
7 changes: 7 additions & 0 deletions src/npm-fastui/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { FooterComp } from './footer'
import { ServerLoadComp } from './ServerLoad'
import { ImageComp } from './image'
import { IframeComp } from './Iframe'
import { RecorderComp } from './MediaRecorder'
import { VideoComp } from './video'
import { FireEventComp } from './FireEvent'
import { ErrorComp } from './error'
Expand Down Expand Up @@ -71,6 +72,7 @@ export {
ServerLoadComp,
ImageComp,
IframeComp,
RecorderComp,
VideoComp,
FireEventComp,
ErrorComp,
Expand Down Expand Up @@ -156,6 +158,11 @@ export const AnyComp: FC<FastProps> = (props) => {
return <ImageComp {...props} />
case 'Iframe':
return <IframeComp {...props} />
case 'MediaTrackSettings':
case 'RecorderOptions':
return <></>
case 'Recorder':
return <RecorderComp {...props} />
case 'Video':
return <VideoComp {...props} />
case 'FireEvent':
Expand Down
58 changes: 58 additions & 0 deletions src/npm-fastui/src/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type FastProps =
| ServerLoad
| Image
| Iframe
| MediaTrackSettings
| RecorderOptions
| Recorder
| Video
| FireEvent
| Error
Expand Down Expand Up @@ -237,6 +240,61 @@ export interface Iframe {
sandbox?: string
type: 'Iframe'
}
export interface MediaTrackSettings {
aspectRatio?: number
autoGainControl?: boolean
channelCount?: number
deviceId?: string
displaySurface?: string
echoCancellation?: boolean
facingMode?: string | ('user' | 'environment')
frameRate?: number
groupId?: string
height?: number
noiseSuppression?: boolean
sampleRate?: number
sampleSize?: number
width?: number
type: 'MediaTrackSettings'
className?: ClassName
}
export interface RecorderOptions {
audioBitsPerSecond?: number
bitsPerSecond?: number
mimeType?: string
videoBitsPerSecond?: number
type: 'RecorderOptions'
className?: ClassName
}
export interface Recorder {
audioConstraints?: MediaTrackSettings | boolean
videoConstraints?: MediaTrackSettings | boolean
peerIdentity?: string
preferCurrentTab?: boolean
options?: RecorderOptions
submitUrl?: string
/**
* Prompt client to save recording.
*/
saveRecording?: boolean
hideText?: boolean
text?: string
stopText?: string
hideImage?: boolean
imageUrl?: string
stopImageUrl?: string
imagePosition?: 'left' | 'right'
imageWidth?: string | number
imageHeight?: string | number
displayStyle?: 'standard' | 'toggle'
/**
* Override the field name used to store the recording Blob data when the form is submitted to the `submit_url` endpoint.
*/
overrideFieldName?: string
type: 'Recorder'
namedStyle?: NamedStyle
className?: ClassName
}
export interface Video {
sources: string[]
autoplay?: boolean
Expand Down
Loading