[Video] Uploads (#4754)

* state for video uploads

* get upload working

* add a debug log

* add post progress

* progress

* fetch data

* add some progress info, web uploads

* post on finished uploading (wip)

* add a note

* add some todos

* clear video

* merge some stuff

* convert to `createUploadTask`

* patch expo modules core

* working native upload progress

* platform fork

* upload progress for web

* cleanup

* cleanup

* more tweaks

* simplify

* fix type errors

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
This commit is contained in:
Hailey 2024-07-30 08:25:31 -07:00 committed by GitHub
parent 43ba0f21f6
commit 8ddb28d3c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 594 additions and 112 deletions

View file

@ -0,0 +1,212 @@
import React from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {logger} from '#/logger'
import {CompressedVideo} from 'lib/media/video/compress'
import {VideoTooLargeError} from 'lib/media/video/errors'
import {JobState, JobStatus} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {createVideoEndpointUrl} from 'state/queries/video/util'
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
type Action =
| {
type: 'SetStatus'
status: Status
}
| {
type: 'SetProgress'
progress: number
}
| {
type: 'SetError'
error: string | undefined
}
| {type: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset}
| {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: JobStatus}
export interface State {
status: Status
progress: number
asset?: ImagePickerAsset
video: CompressedVideo | null
jobStatus?: JobStatus
error?: string
}
function reducer(state: State, action: Action): State {
let updatedState = state
if (action.type === 'SetStatus') {
updatedState = {...state, status: action.status}
} else if (action.type === 'SetProgress') {
updatedState = {...state, progress: action.progress}
} else if (action.type === 'SetError') {
updatedState = {...state, error: action.error}
} else if (action.type === 'Reset') {
updatedState = {
status: 'idle',
progress: 0,
video: null,
}
} else if (action.type === 'SetAsset') {
updatedState = {...state, asset: action.asset}
} else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
}
return updatedState
}
export function useUploadVideo({
setStatus,
onSuccess,
}: {
setStatus: (status: string) => void
onSuccess: () => void
}) {
const {_} = useLingui()
const [state, dispatch] = React.useReducer(reducer, {
status: 'idle',
progress: 0,
video: null,
})
const {setJobId} = useUploadStatusQuery({
onStatusChange: (status: JobStatus) => {
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
// Leaving it for now though
dispatch({
type: 'SetJobStatus',
jobStatus: status,
})
setStatus(status.state.toString())
},
onSuccess: () => {
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess()
},
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
onSuccess: response => {
dispatch({
type: 'SetStatus',
status: 'processing',
})
setJobId(response.job_id)
},
onError: e => {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onError: e => {
if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
})
} else {
dispatch({
type: 'SetError',
// @TODO better error message from server, left untranslated on purpose
error: 'An error occurred while compressing the video.',
})
logger.error('Error compressing video', {safeMessage: e})
}
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
dispatch({
type: 'SetStatus',
status: 'uploading',
})
onVideoCompressed(video)
},
})
const selectVideo = (asset: ImagePickerAsset) => {
dispatch({
type: 'SetAsset',
asset,
})
dispatch({
type: 'SetStatus',
status: 'compressing',
})
onSelectVideo(asset)
}
const clearVideo = () => {
// @TODO cancel any running jobs
dispatch({type: 'Reset'})
}
return {
state,
dispatch,
selectVideo,
clearVideo,
}
}
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
}: {
onStatusChange: (status: JobStatus) => void
onSuccess: () => void
}) => {
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState<string>()
const {isLoading, isError} = useQuery({
queryKey: ['video-upload'],
queryFn: async () => {
const url = createVideoEndpointUrl(`/job/${jobId}/status`)
const res = await fetch(url)
const status = (await res.json()) as JobStatus
if (status.state === JobState.JOB_STATE_COMPLETED) {
setEnabled(false)
onSuccess()
}
onStatusChange(status)
return status
},
enabled: Boolean(jobId && enabled),
refetchInterval: 1500,
})
return {
isLoading,
isError,
setJobId: (_jobId: string) => {
setJobId(_jobId)
},
}
}