diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx index 2a9f01e5..f0e82a22 100644 --- a/src/npm-fastui-bootstrap/src/index.tsx +++ b/src/npm-fastui-bootstrap/src/index.tsx @@ -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': diff --git a/src/npm-fastui/src/components/MediaRecorder.tsx b/src/npm-fastui/src/components/MediaRecorder.tsx index dfdb0b7d..664ca620 100644 --- a/src/npm-fastui/src/components/MediaRecorder.tsx +++ b/src/npm-fastui/src/components/MediaRecorder.tsx @@ -1,141 +1,211 @@ -import { useEffect, useState, type FC } from 'react'; - -export interface MediaRecorderCompProps { - audioConstraints?: MediaStreamConstraints['audio']; - videoConstraints?: MediaStreamConstraints['video']; - peerIdentity?: string; - preferCurrentTab?: boolean; - options?: MediaRecorderOptions; - onRecordingStart?: () => void; - onRecordingComplete: (blob: Blob) => void; - hideText?: boolean; - startText?: string; - stopText?: string; - hideImage?: boolean; - startImageUrl?: string; - stopImageUrl?: string; - imagePosition?: 'left' | 'right'; - displayStyle?: 'standard' | 'toggle'; -} +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 = (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(null) + const mediaRecorderRef = useRef(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) + } -export const MediaRecorderComp: FC = ({ - audioConstraints = true, - videoConstraints = false, - peerIdentity, - preferCurrentTab, - options, - onRecordingStart, - onRecordingComplete, - hideText = false, - startText = 'Start Recording', - stopText = 'Stop Recording', - hideImage = false, - startImageUrl, - stopImageUrl, - imagePosition = 'left', - displayStyle = 'standard', -}) => { - const [buttonStartText, setButtonStartText] = useState(startText); - const [buttonStopText, setButtonStopText] = useState(stopText); - const [buttonTextVisible, setButtonTextVisible] = useState(!hideText); - const [buttonStartImageUrl, setButtonStartImageUrl] = useState(startImageUrl); - const [buttonStopImageUrl, setButtonStopImageUrl] = useState(stopImageUrl); - const [buttonImageVisible, setButtonImageVisible] = useState(!hideImage); - const [buttonDisplayStyle, setButtonDisplayStyle] = useState(displayStyle); - const [mediaRecorder, setMediaRecorder] = useState(null); - const [isRecording, setIsRecording] = useState(false); + const mediaStreamConstraints: MediaStreamConstraints = useMemo( + () => ({ + audio: audioConstraints ?? true, + video: videoConstraints ?? false, + peerIdentity, + preferCurrentTab, + }), + [audioConstraints, videoConstraints, peerIdentity, preferCurrentTab], + ) useEffect(() => { - setButtonTextVisible(!hideText); - setButtonStartText(hideText ? '' : startText); - setButtonStopText(hideText ? '' : stopText); - setButtonStartImageUrl(hideImage ? '' : startImageUrl); - setButtonStopImageUrl(hideImage ? '' : stopImageUrl); - setButtonImageVisible(!hideImage); - setButtonDisplayStyle(displayStyle); - }, [startText, stopText, hideText, startImageUrl, stopImageUrl, hideImage, displayStyle]); + mediaRecorderRef.current = mediaRecorder + }, [mediaRecorder]) useEffect(() => { // initialize media recording - const initMediaRecorder = async () => { + const initMediaRecorder = async (): Promise => { try { - const constraints: MediaStreamConstraints = { - audio: audioConstraints ?? true, - video: videoConstraints ?? false, - peerIdentity, - preferCurrentTab, + 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 + } + } } - const stream = await navigator.mediaDevices.getUserMedia(constraints); - const recorder = new MediaRecorder(stream, options); - setMediaRecorder(recorder); + setMediaRecorder(recorder) } catch (error) { - console.error('Error initializing media recorder', error); + console.error('Error initializing media recorder', error) } - }; + } - initMediaRecorder(); + initMediaRecorder() - return () => mediaRecorder?.stream?.getTracks?.()?.forEach(track => track.stop()); - }, [audioConstraints, videoConstraints, peerIdentity, preferCurrentTab, options]); + return () => mediaRecorderRef.current?.stream?.getTracks?.()?.forEach((track) => track.stop()) + }, [options, saveRecordings, recordingFieldName, recordingSubmitUrl, mediaStreamConstraints]) - const handleStartRecording = () => { + const handleStartRecording = (): void => { if (!mediaRecorder) { - console.error('Media recorder not initialized'); - return; + console.error('Media recorder not initialized') + return } - onRecordingStart?.(); - mediaRecorder.start(); - setIsRecording(true); - console.log('Recording started'); + setIsRecording(true) + mediaRecorder.start() + console.log('Recording started') } - const handleStopRecording = () => { + const handleStopRecording = (): void => { if (!mediaRecorder) { - console.error('Media recorder not initialized'); - return; - } - mediaRecorder.stop(); - setIsRecording(false); - console.log('Recording stopped'); - - // get recorded data - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - onRecordingComplete(event.data); - } + console.error('Media recorder not initialized') + return } - }; - - const handleOnClick = () => isRecording ? handleStopRecording() : handleStartRecording(); - const displayText = () => isRecording ? buttonStopText : buttonStartText; - const displayImageUrl = () => isRecording ? buttonStopImageUrl : buttonStartImageUrl; - - const renderButton = (text?: string, imageUrl?: string, disabled = false) => ( - - ); - - const renderStandardButtons = () => ( + mediaRecorder.stop() + setIsRecording(false) + console.log('Recording stopped') + } + + const handleOnClick: MouseEventHandler = (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 ( + + ) + } + + const StandardButtons: FC = (): JSX.Element => ( <> - {renderButton(buttonStartText, buttonStartImageUrl, isRecording)} - {renderButton(buttonStopText, buttonStopImageUrl, !isRecording)} + + - ); + ) + + const ToggleButton: FC = (): JSX.Element => { + const displayText = () => (isRecording ? buttonStopText : buttonStartText) + const displayImageUrl = () => (isRecording ? buttonStopImageUrl : buttonStartImageUrl) + return + } return ( - <> - {buttonDisplayStyle === 'standard' && renderStandardButtons()} - {buttonDisplayStyle === 'toggle' && renderButton(displayText(), displayImageUrl())} - - ); +
+ {buttonDisplayStyle === 'toggle' ? : } +
+ ) } - diff --git a/src/npm-fastui/src/components/index.tsx b/src/npm-fastui/src/components/index.tsx index d0398a75..7252c37e 100644 --- a/src/npm-fastui/src/components/index.tsx +++ b/src/npm-fastui/src/components/index.tsx @@ -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' @@ -71,6 +72,7 @@ export { ServerLoadComp, ImageComp, IframeComp, + RecorderComp, VideoComp, FireEventComp, ErrorComp, @@ -156,6 +158,11 @@ export const AnyComp: FC = (props) => { return case 'Iframe': return + case 'MediaTrackSettings': + case 'RecorderOptions': + return <> + case 'Recorder': + return case 'Video': return case 'FireEvent': diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index a0ca91cd..d911fad3 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -23,6 +23,9 @@ export type FastProps = | ServerLoad | Image | Iframe + | MediaTrackSettings + | RecorderOptions + | Recorder | Video | FireEvent | Error @@ -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 diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index f2d3f423..784d61e0 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -46,6 +46,10 @@ 'ServerLoad', 'Image', 'Iframe', + 'MediaTrackSettings', + 'RecorderOptions', + 'Recorder', + 'Video', 'FireEvent', 'Error', 'Spinner', @@ -261,6 +265,74 @@ class Iframe(_p.BaseModel, extra='forbid'): type: _t.Literal['Iframe'] = 'Iframe' +class MediaTrackSettings(_p.BaseModel, extra='forbid'): + aspect_ratio: _t.Union[float, None] = _p.Field(default=None, serialization_alias='aspectRatio') + auto_gain_control: _t.Union[bool, None] = _p.Field(default=None, serialization_alias='autoGainControl') + channel_count: _t.Union[int, None] = _p.Field(default=None, serialization_alias='channelCount') + device_id: _t.Union[str, None] = _p.Field(default=None, serialization_alias='deviceId') + display_surface: _t.Union[str, None] = _p.Field(default=None, serialization_alias='displaySurface') + echo_cancellation: _t.Union[bool, None] = _p.Field(default=None, serialization_alias='echoCancellation') + facing_mode: _t.Union[str, _t.Literal['user', 'environment'], None] = _p.Field( + default=None, serialization_alias='facingMode' + ) + frame_rate: _t.Union[float, None] = _p.Field(default=None, serialization_alias='frameRate') + group_id: _t.Union[str, None] = _p.Field(default=None, serialization_alias='groupId') + height: _t.Union[int, None] = None + noise_suppression: _t.Union[bool, None] = _p.Field(default=None, serialization_alias='noiseSuppression') + sample_rate: _t.Union[int, None] = _p.Field(default=None, serialization_alias='sampleRate') + sample_size: _t.Union[int, None] = _p.Field(default=None, serialization_alias='sampleSize') + width: _t.Union[int, None] = None + type: _t.Literal['MediaTrackSettings'] = 'MediaTrackSettings' + class_name: _class_name.ClassNameField = None + + +class RecorderOptions(_p.BaseModel, extra='forbid'): + audio_bits_per_second: _t.Union[int, None] = _p.Field(default=None, serialization_alias='audioBitsPerSecond') + bits_per_second: _t.Union[int, None] = _p.Field(default=None, serialization_alias='bitsPerSecond') + mime_type: _t.Union[str, None] = _p.Field(default=None, serialization_alias='mimeType') + video_bits_per_second: _t.Union[int, None] = _p.Field(default=None, serialization_alias='videoBitsPerSecond') + type: _t.Literal['RecorderOptions'] = 'RecorderOptions' + class_name: _class_name.ClassNameField = None + + +class Recorder(_p.BaseModel, extra='forbid'): + audio_constraints: _t.Union[MediaTrackSettings, bool, None] = _p.Field( + default=True, serialization_alias='audioConstraints' + ) + video_constraints: _t.Union[MediaTrackSettings, bool, None] = _p.Field( + default=False, serialization_alias='videoConstraints' + ) + peer_identity: _t.Union[str, None] = _p.Field(default=None, serialization_alias='peerIdentity') + prefer_current_tab: _t.Union[bool, None] = _p.Field(default=None, serialization_alias='preferCurrentTab') + options: _t.Union[RecorderOptions, None] = None + submit_url: _t.Union[str, None] = _p.Field(default=None, serialization_alias='submitUrl') + save_recording: _t.Union[bool, None] = _p.Field( + default=False, serialization_alias='saveRecording', description='Prompt client to save recording.' + ) + hide_text: _t.Union[bool, None] = _p.Field(default=False, serialization_alias='hideText') + text: _t.Union[str, None] = _p.Field(default='Start Recording', serialization_alias='text') + stop_text: _t.Union[str, None] = _p.Field(default='Stop Recording', serialization_alias='stopText') + hide_image: _t.Union[bool, None] = _p.Field(default=False, serialization_alias='hideImage') + image_url: _t.Union[_p.AnyUrl, None] = _p.Field(default=None, serialization_alias='imageUrl') + stop_image_url: _t.Union[_p.AnyUrl, None] = _p.Field(default=None, serialization_alias='stopImageUrl') + image_position: _t.Union[_t.Literal['left', 'right'], None] = _p.Field( + default='left', serialization_alias='imagePosition' + ) + image_width: _t.Union[str, int, None] = _p.Field(default=None, serialization_alias='imageWidth') + image_height: _t.Union[str, int, None] = _p.Field(default=None, serialization_alias='imageHeight') + display_style: _t.Union[_t.Literal['standard', 'toggle'], None] = _p.Field( + default='standard', serialization_alias='displayStyle' + ) + override_field_name: _t.Union[str, None] = _p.Field( + default='recording', + serialization_alias='overrideFieldName', + description='Override the field name used to store the recording Blob data when the form is submitted to the `submit_url` endpoint.', + ) + type: _t.Literal['Recorder'] = 'Recorder' + named_style: _class_name.NamedStyleField = None + class_name: _class_name.ClassNameField = None + + class Video(_p.BaseModel, extra='forbid'): sources: _t.List[_p.AnyUrl] autoplay: _t.Union[bool, None] = None @@ -331,6 +403,9 @@ class Custom(_p.BaseModel, extra='forbid'): ServerLoad, Image, Iframe, + MediaTrackSettings, + RecorderOptions, + Recorder, Video, FireEvent, Error,