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)

View File

@@ -302,6 +302,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
isFilesSmartView={isFilesSmartView}
optionsSubtitle={optionsSubtitle}
selectedTag={selectedTag}
filesController={filesController}
/>
)}
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />

View File

@@ -0,0 +1,127 @@
import PhotoCaptureModal from '@/Components/CameraCaptureModal/PhotoCaptureModal'
import VideoCaptureModal from '@/Components/CameraCaptureModal/VideoCaptureModal'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
import Popover from '@/Components/Popover/Popover'
import { FilesController } from '@/Controllers/FilesController'
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
import { classNames } from '@standardnotes/snjs'
import { useEffect, useRef, useState } from 'react'
type Props = {
isDailyEntry: boolean
isInFilesSmartView: boolean
addButtonLabel: string
addNewItem: () => void
filesController: FilesController
}
const AddItemMenuButton = ({
filesController,
isDailyEntry,
addButtonLabel,
isInFilesSmartView,
addNewItem,
}: Props) => {
const addItemButtonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [deviceHasCamera, setDeviceHasCamera] = useState(false)
const [captureType, setCaptureType] = useState<'photo' | 'video'>()
useEffect(() => {
const setCameraSupport = async () => {
setDeviceHasCamera(await PhotoRecorder.isSupported())
}
void setCameraSupport()
}, [])
const canShowMenu = isInFilesSmartView && deviceHasCamera
return (
<>
<button
className={classNames(
'hidden md:flex',
'h-8 w-8 hover:brightness-125',
'z-editor-title-bar ml-3 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
)}
title={addButtonLabel}
aria-label={addButtonLabel}
onClick={() => {
if (canShowMenu) {
setIsMenuOpen((isOpen) => !isOpen)
} else {
addNewItem()
}
}}
ref={addItemButtonRef}
>
<Icon type="add" size="custom" className="h-5 w-5" />
</button>
<Popover
open={canShowMenu && isMenuOpen}
anchorElement={addItemButtonRef.current}
togglePopover={() => {
setIsMenuOpen((isOpen) => !isOpen)
}}
side="bottom"
align="center"
className="py-2"
>
<Menu a11yLabel={'test'} isOpen={isMenuOpen}>
<MenuItem
onClick={() => {
addNewItem()
setIsMenuOpen(false)
}}
>
<Icon type="add" className="mr-2" />
{addButtonLabel}
</MenuItem>
<MenuItem
onClick={async () => {
setCaptureType('photo')
setIsMenuOpen(false)
}}
>
<Icon type="camera" className="mr-2" />
Take photo
</MenuItem>
<MenuItem
onClick={async () => {
setCaptureType('video')
setIsMenuOpen(false)
}}
>
<Icon type="camera" className="mr-2" />
Record video
</MenuItem>
</Menu>
</Popover>
{captureType === 'photo' && (
<PhotoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
{captureType === 'video' && (
<VideoCaptureModal
filesController={filesController}
close={() => {
setCaptureType(undefined)
}}
/>
)}
</>
)
}
export default AddItemMenuButton

View File

@@ -9,6 +9,8 @@ import { isTag, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import RoundIconButton from '@/Components/Button/RoundIconButton'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import AddItemMenuButton from './AddItemMenuButton'
import { FilesController } from '@/Controllers/FilesController'
type Props = {
application: WebApplication
@@ -19,6 +21,7 @@ type Props = {
isFilesSmartView: boolean
optionsSubtitle?: string
selectedTag: AnyTag
filesController: FilesController
}
const ContentListHeader = ({
@@ -30,6 +33,7 @@ const ContentListHeader = ({
isFilesSmartView,
optionsSubtitle,
selectedTag,
filesController,
}: Props) => {
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
@@ -84,23 +88,15 @@ const ContentListHeader = ({
const AddButton = useMemo(() => {
return (
<button
className={classNames(
'hidden md:flex',
'h-8 w-8 hover:brightness-125',
'z-editor-title-bar ml-3 cursor-pointer items-center',
`justify-center rounded-full border border-solid border-transparent ${
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
}`,
)}
title={addButtonLabel}
aria-label={addButtonLabel}
onClick={addNewItem}
>
<Icon type="add" size="custom" className="h-5 w-5" />
</button>
<AddItemMenuButton
isInFilesSmartView={isFilesSmartView}
isDailyEntry={isDailyEntry}
addButtonLabel={addButtonLabel}
addNewItem={addNewItem}
filesController={filesController}
/>
)
}, [addButtonLabel, addNewItem, isDailyEntry])
}, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView])
const FolderName = useMemo(() => {
return (

View File

@@ -6,6 +6,7 @@ import Icon from '@/Components/Icon/Icon'
import { DropdownItem } from './DropdownItem'
import StyledListboxButton from './StyledListboxButton'
import StyledListboxOption from './StyledListboxOption'
import { classNames } from '@standardnotes/snjs'
type DropdownProps = {
id: string
@@ -14,7 +15,11 @@ type DropdownProps = {
value: string
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
className?: string
className?: {
wrapper?: string
button?: string
popover?: string
}
fullWidth?: boolean
portal?: boolean
}
@@ -63,12 +68,16 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
onChange(value, selectedItem)
}
const wrapperClassName = className?.wrapper ?? ''
const buttonClassName = className?.button ?? ''
const popoverClassName = className?.popover ?? ''
return (
<div className={className}>
<div className={wrapperClassName}>
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<StyledListboxButton
className={`w-full ${!fullWidth ? 'md:w-fit' : ''}`}
className={classNames('w-full', !fullWidth && 'md:w-fit', buttonClassName)}
children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null
@@ -82,7 +91,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
})
}}
/>
<ListboxPopover portal={portal} className="sn-dropdown sn-dropdown-popover">
<ListboxPopover portal={portal} className={classNames('sn-dropdown sn-dropdown-popover', popoverClassName)}>
<div className="sn-component">
<ListboxList>
{items.map((item) => (

View File

@@ -62,6 +62,7 @@ export const IconNameToSvgMapping = {
archive: icons.ArchiveIcon,
asterisk: icons.AsteriskIcon,
authenticator: icons.AuthenticatorIcon,
camera: icons.CameraIcon,
check: icons.CheckIcon,
close: icons.CloseIcon,
code: icons.CodeIcon,
@@ -106,6 +107,7 @@ export const IconNameToSvgMapping = {
tune: icons.TuneIcon,
unarchive: icons.UnarchiveIcon,
unpin: icons.UnpinIcon,
upload: icons.UploadIcon,
user: icons.UserIcon,
view: icons.ViewIcon,
warning: icons.WarningIcon,

View File

@@ -1,5 +1,5 @@
import { classNames } from '@standardnotes/utils'
import { Fragment, FunctionComponent, ReactNode } from 'react'
import { FunctionComponent, ReactNode } from 'react'
type Props = {
className?: string
@@ -9,16 +9,7 @@ type Props = {
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
<>
<hr className="m-0 h-[1px] border-none bg-border" />
<div className={classNames('flex items-center justify-end px-4 py-4', className)}>
{children != undefined && Array.isArray(children)
? children.map((child, idx, arr) => (
<Fragment key={idx}>
{child}
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
</Fragment>
))
: children}
</div>
<div className={classNames('flex items-center justify-end gap-3 px-4 py-4', className)}>{children}</div>
</>
)

View File

@@ -101,17 +101,17 @@ export class MomentsService extends AbstractViewController {
}
const filename = `Moment ${dateToStringStyle1(new Date())}.png`
const camera = new PhotoRecorder(filename)
const camera = new PhotoRecorder()
await camera.initialize()
if (this.application.isMobileDevice) {
await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS)
}
let file = await camera.takePhoto()
let file = await camera.takePhoto(filename)
if (!file) {
await sleep(1000)
file = await camera.takePhoto()
file = await camera.takePhoto(filename)
if (!file) {
return undefined
}

View File

@@ -1,20 +1,52 @@
export class PhotoRecorder {
public video!: HTMLVideoElement
public devices!: MediaDeviceInfo[]
public selectedDevice!: MediaDeviceInfo
private canvas!: HTMLCanvasElement
private width!: number
private height!: number
private stream!: MediaStream
constructor(private fileName: string) {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor() {}
public static async isSupported(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((device) => device.kind === 'videoinput')
return hasCamera
}
public async setDevice(deviceId: string) {
this.selectedDevice = this.devices.find((device) => device.deviceId === deviceId) ?? this.devices[0]
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: this.selectedDevice.deviceId,
},
audio: false,
})
this.video.srcObject = this.stream
await this.video.play()
await this.awaitVideoReady(this.video)
}
public async initialize() {
this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
this.devices = (await navigator.mediaDevices.enumerateDevices()).filter((device) => device.kind === 'videoinput')
this.selectedDevice = this.devices[0]
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: this.selectedDevice.deviceId,
},
audio: false,
})
this.video = document.createElement('video')
this.video.playsInline = true
this.video.style.position = 'absolute'
this.video.style.display = 'none'
this.video.oncontextmenu = (e) => e.preventDefault()
this.canvas = document.createElement('canvas')
@@ -33,7 +65,7 @@ export class PhotoRecorder {
this.canvas.height = this.height
}
public async takePhoto(): Promise<File | undefined> {
public async takePhoto(fileName: string): Promise<File | undefined> {
const context = this.canvas.getContext('2d')
context?.drawImage(this.video, 0, 0, this.width, this.height)
const dataUrl = this.canvas.toDataURL('image/png')
@@ -46,7 +78,7 @@ export class PhotoRecorder {
const res: Response = await fetch(dataUrl)
const blob: Blob = await res.blob()
const file = new File([blob], this.fileName, { type: 'image/png' })
const file = new File([blob], fileName, { type: 'image/png' })
return file
}

View File

@@ -13,6 +13,12 @@ export class VideoRecorder {
constructor(private fileName: string) {}
public static async isSupported(): Promise<boolean> {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((device) => device.kind === 'videoinput')
return hasCamera
}
public async initialize() {
this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
this.recorder = new MediaRecorder(this.stream)
@@ -58,7 +64,9 @@ export class VideoRecorder {
public async stop(): Promise<File> {
this.video.pause()
this.recorder.stop()
if (this.recorder.state !== 'inactive') {
this.recorder.stop()
}
this.video.parentElement?.removeChild(this.video)
this.canvas.parentElement?.removeChild(this.canvas)