feat: Ability to record videos and capture photos directly in app by selecting + in Files view (#2095)

This commit is contained in:
Aman Harwara
2022-12-13 21:41:41 +05:30
committed by GitHub
parent cd8596b14e
commit d4b63e4ea6
11 changed files with 552 additions and 39 deletions

View File

@@ -0,0 +1,181 @@
import { FilesController } from '@/Controllers/FilesController'
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Dropdown from '../Dropdown/Dropdown'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
type Props = {
filesController: FilesController
close: () => void
}
const PhotoCaptureModal = ({ filesController, close }: Props) => {
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
const [recorder, setRecorder] = useState<PhotoRecorder | undefined>(() => new PhotoRecorder())
const [isRecorderReady, setIsRecorderReady] = useState(false)
const [capturedPhoto, setCapturedPhoto] = useState<File>()
const fileNameInputRef = useRef<HTMLInputElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!recorder) {
return
}
setIsRecorderReady(false)
const init = async () => {
await recorder.initialize()
if (previewRef.current) {
recorder.video.style.position = ''
recorder.video.style.display = ''
recorder.video.style.height = '100%'
previewRef.current.append(recorder.video)
}
setIsRecorderReady(true)
}
void init()
return () => {
if (recorder.video) {
recorder.finish()
}
}
}, [recorder])
const takePhoto = useCallback(async () => {
if (!recorder) {
return
}
const file = await recorder.takePhoto(fileName)
setCapturedPhoto(file)
setRecorder(undefined)
}, [fileName, recorder])
const devicesAsDropdownItems = useMemo(() => {
return recorder?.devices
? recorder.devices.map((device) => ({
label: device.label || `Camera (${device.deviceId.slice(0, 10)})`,
value: device.deviceId,
}))
: []
}, [recorder?.devices])
const savePhoto = useCallback(() => {
if (!fileName) {
fileNameInputRef.current?.focus()
return
}
if (!capturedPhoto) {
return
}
void filesController.uploadNewFile(capturedPhoto)
close()
}, [capturedPhoto, close, fileName, filesController])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Take a photo</ModalDialogLabel>
<ModalDialogDescription>
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
<DecoratedInput
className={{
container: 'mt-1',
}}
value={fileName}
onChange={(fileName) => setFileName(fileName)}
ref={fileNameInputRef}
/>
</label>
</div>
<div className="mt-2">
<div className="text-sm font-medium text-neutral">Preview:</div>
{!isRecorderReady && (
<div className="mt-1 w-full">
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
<Icon type="camera" className="text-neutral-300" />
Initializing...
</div>
</div>
)}
<div className={classNames('mt-1 w-full', capturedPhoto && 'hidden')} ref={previewRef}></div>
{capturedPhoto && (
<div className="mt-1 w-full">
<img src={URL.createObjectURL(capturedPhoto)} alt="Captured photo" />
</div>
)}
</div>
{recorder && devicesAsDropdownItems.length > 1 && !capturedPhoto && (
<div className="mt-4">
<label className="text-sm font-medium text-neutral">
Device:
<Dropdown
id={'photo-capture-device-dropdown'}
label={'Photo Capture Device'}
items={devicesAsDropdownItems}
value={recorder.selectedDevice.deviceId}
onChange={(value: string) => {
void recorder.setDevice(value)
}}
className={{
wrapper: 'mt-1',
popover: 'z-modal',
}}
/>
</label>
</div>
)}
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedPhoto && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={() => {
void takePhoto()
}}
>
<Icon type="camera" />
Take photo
</Button>
)}
{capturedPhoto && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedPhoto(undefined)
setRecorder(new PhotoRecorder())
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={savePhoto}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(PhotoCaptureModal)

View File

@@ -0,0 +1,166 @@
import { FilesController } from '@/Controllers/FilesController'
import { VideoRecorder } from '@/Controllers/Moments/VideoRecorder'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { classNames } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import ModalDialog from '../Shared/ModalDialog'
import ModalDialogButtons from '../Shared/ModalDialogButtons'
import ModalDialogDescription from '../Shared/ModalDialogDescription'
import ModalDialogLabel from '../Shared/ModalDialogLabel'
type Props = {
filesController: FilesController
close: () => void
}
const VideoCaptureModal = ({ filesController, close }: Props) => {
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
const [recorder, setRecorder] = useState(() => new VideoRecorder(fileName))
const [isRecorderReady, setIsRecorderReady] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [capturedVideo, setCapturedVideo] = useState<File>()
const fileNameInputRef = useRef<HTMLInputElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const init = async () => {
await recorder.initialize()
if (previewRef.current) {
recorder.video.style.position = ''
recorder.video.style.display = ''
recorder.video.style.height = '100%'
previewRef.current.append(recorder.video)
}
setIsRecorderReady(true)
}
void init()
return () => {
if (recorder.video) {
void recorder.stop()
}
}
}, [recorder])
const startRecording = useCallback(async () => {
await recorder.startRecording()
setIsRecording(true)
}, [recorder])
const saveVideo = useCallback(() => {
if (!fileName) {
fileNameInputRef.current?.focus()
return
}
if (!capturedVideo) {
return
}
const namedFile = new File([capturedVideo], fileName, {
type: capturedVideo.type,
})
void filesController.uploadNewFile(namedFile)
close()
}, [capturedVideo, close, fileName, filesController])
const capturedVideoObjectURL = useMemo(() => {
if (!capturedVideo) {
return
}
return URL.createObjectURL(capturedVideo)
}, [capturedVideo])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Record a video</ModalDialogLabel>
<ModalDialogDescription>
<div className="mb-4 flex flex-col">
<label className="text-sm font-medium text-neutral">
File name:
<DecoratedInput
className={{
container: 'mt-1',
}}
value={fileName}
onChange={(fileName) => setFileName(fileName)}
ref={fileNameInputRef}
/>
</label>
</div>
<div className="mt-2">
<div className="text-sm font-medium text-neutral">Preview:</div>
{!isRecorderReady && (
<div className="mt-1 w-full">
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
<Icon type="camera" className="text-neutral-300" />
Initializing...
</div>
</div>
)}
<div className={classNames('mt-1 w-full', capturedVideo && 'hidden')} ref={previewRef}></div>
{capturedVideo && (
<div className="mt-1 w-full">
<video src={capturedVideoObjectURL} controls />
</div>
)}
</div>
</ModalDialogDescription>
<ModalDialogButtons>
{!capturedVideo && !isRecording && (
<Button
primary
className="flex items-center gap-2"
onClick={() => {
void startRecording()
}}
>
<Icon type="camera" />
Start recording
</Button>
)}
{!capturedVideo && isRecording && (
<Button
primary
colorStyle="danger"
className="flex items-center gap-2"
onClick={async () => {
const capturedVideo = await recorder.stop()
setIsRecording(false)
setCapturedVideo(capturedVideo)
}}
>
<Icon type="camera" />
Stop recording
</Button>
)}
{capturedVideo && (
<div className="flex items-center gap-2">
<Button
className="flex items-center gap-2"
onClick={() => {
setCapturedVideo(undefined)
setRecorder(new VideoRecorder(fileName))
setIsRecorderReady(false)
}}
>
Retry
</Button>
<Button primary className="flex items-center gap-2" onClick={saveVideo}>
<Icon type="upload" />
Upload
</Button>
</div>
)}
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(VideoCaptureModal)