[Video] Upload errors and UI improvements (#5092)

* surface errors in UI

* style progress indicator

* remove job status progress

* rm log

* fix webm ext
zio/stable
Samuel Newman 2024-09-03 15:09:09 +01:00 committed by GitHub
parent f9d736653c
commit 0e1de19903
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 155 additions and 60 deletions

View File

@ -29,5 +29,6 @@ export async function compressVideo(
) )
const info = await getVideoMetaData(compressed) const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
return {uri: compressed, size: info.size, mimeType: `video/${info.extension}`}
} }

View File

@ -23,6 +23,7 @@ export async function compressVideo(
size: blob.size, size: blob.size,
uri, uri,
bytes: await blob.arrayBuffer(), bytes: await blob.arrayBuffer(),
mimeType,
} }
} }

View File

@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
this.name = 'VideoTooLargeError' this.name = 'VideoTooLargeError'
} }
} }
export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}

View File

@ -1,5 +1,6 @@
export type CompressedVideo = { export type CompressedVideo = {
uri: string uri: string
mimeType: string
size: number size: number
// web only, can fall back to uri if missing // web only, can fall back to uri if missing
bytes?: ArrayBuffer bytes?: ArrayBuffer

View File

@ -24,3 +24,16 @@ export function useVideoAgent() {
}) })
}, []) }, [])
} }
export function mimeToExt(mimeType: string) {
switch (mimeType) {
case 'video/mp4':
return 'mp4'
case 'video/webm':
return 'webm'
case 'video/mpeg':
return 'mpeg'
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}

View File

@ -1,11 +1,14 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system' import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs} from '@atproto/api' import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@ -22,13 +25,14 @@ export const useUploadVideoMutation = ({
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const {_} = useLingui()
return useMutation({ return useMutation({
mutationKey: ['video', 'upload'], mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => { mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
video.uri, video.uri,
{ {
headers: { headers: {
'content-type': 'video/mp4', 'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`, Authorization: `Bearer ${serviceAuth.token}`,
}, },
httpMethod: 'POST', httpMethod: 'POST',
@ -65,6 +69,13 @@ export const useUploadVideoMutation = ({
} }
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || _(msg`Failed to upload video`),
)
}
return responseBody return responseBody
}, signal), }, signal),
onError, onError,

View File

@ -1,10 +1,13 @@
import {AppBskyVideoDefs} from '@atproto/api' import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@ -21,13 +24,14 @@ export const useUploadVideoMutation = ({
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const {_} = useLingui()
return useMutation({ return useMutation({
mutationKey: ['video', 'upload'], mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => { mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
@ -63,23 +67,24 @@ export const useUploadVideoMutation = ({
xhr.responseText, xhr.responseText,
) as AppBskyVideoDefs.JobStatus ) as AppBskyVideoDefs.JobStatus
resolve(uploadRes) resolve(uploadRes)
onSuccess(uploadRes)
} else { } else {
reject() reject(new ServerError(_(msg`Failed to upload video`)))
onError(new Error('Failed to upload video'))
} }
} }
xhr.onerror = () => { xhr.onerror = () => {
reject() reject(new ServerError(_(msg`Failed to upload video`)))
onError(new Error('Failed to upload video'))
} }
xhr.open('POST', uri) xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', 'video/mp4') xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.send(bytes) xhr.send(bytes)
}, },
) )
if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}
return res return res
}, signal), }, signal),
onError, onError,

View File

@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {VideoTooLargeError} from 'lib/media/video/errors' import {isWeb} from '#/platform/detection'
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from 'lib/media/video/types' import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video' import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util' import {useVideoAgent} from 'state/queries/video/util'
@ -58,7 +59,12 @@ function reducer(queryClient: QueryClient) {
abortController: new AbortController(), abortController: new AbortController(),
} }
} else if (action.type === 'SetAsset') { } else if (action.type === 'SetAsset') {
updatedState = {...state, asset: action.asset} updatedState = {
...state,
asset: action.asset,
status: 'compressing',
error: undefined,
}
} else if (action.type === 'SetDimensions') { } else if (action.type === 'SetDimensions') {
updatedState = { updatedState = {
...state, ...state,
@ -67,11 +73,11 @@ function reducer(queryClient: QueryClient) {
: undefined, : undefined,
} }
} else if (action.type === 'SetVideo') { } else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video} updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') { } else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus} updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetBlobRef') { } else if (action.type === 'SetBlobRef') {
updatedState = {...state, blobRef: action.blobRef} updatedState = {...state, blobRef: action.blobRef, status: 'done'}
} }
return updatedState return updatedState
} }
@ -108,10 +114,6 @@ export function useUploadVideo({
type: 'SetBlobRef', type: 'SetBlobRef',
blobRef, blobRef,
}) })
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess() onSuccess()
}, },
}) })
@ -125,10 +127,17 @@ export function useUploadVideo({
setJobId(response.jobId) setJobId(response.jobId)
}, },
onError: e => { onError: e => {
dispatch({ if (e instanceof ServerError) {
type: 'SetError', dispatch({
error: _(msg`An error occurred while uploading the video.`), type: 'SetError',
}) error: e.message,
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
}
logger.error('Error uploading video', {safeMessage: e}) logger.error('Error uploading video', {safeMessage: e})
}, },
setProgress: p => { setProgress: p => {
@ -141,6 +150,13 @@ export function useUploadVideo({
onProgress: p => { onProgress: p => {
dispatch({type: 'SetProgress', progress: p}) dispatch({type: 'SetProgress', progress: p})
}, },
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => { onError: e => {
if (e instanceof VideoTooLargeError) { if (e instanceof VideoTooLargeError) {
dispatch({ dispatch({
@ -150,36 +166,28 @@ export function useUploadVideo({
} else { } else {
dispatch({ dispatch({
type: 'SetError', type: 'SetError',
// @TODO better error message from server, left untranslated on purpose error: _(msg`An error occurred while compressing the video.`),
error: 'An error occurred while compressing the video.',
}) })
logger.error('Error compressing video', {safeMessage: e}) logger.error('Error compressing video', {safeMessage: e})
} }
}, },
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
dispatch({
type: 'SetStatus',
status: 'uploading',
})
onVideoCompressed(video)
},
signal: state.abortController.signal, signal: state.abortController.signal,
}) })
const selectVideo = (asset: ImagePickerAsset) => { const selectVideo = (asset: ImagePickerAsset) => {
dispatch({ switch (getMimeType(asset)) {
type: 'SetAsset', case 'video/mp4':
asset, case 'video/mpeg':
}) case 'video/webm':
dispatch({ dispatch({
type: 'SetStatus', type: 'SetAsset',
status: 'compressing', asset,
}) })
onSelectVideo(asset) onSelectVideo(asset)
break
default:
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
}
} }
const clearVideo = () => { const clearVideo = () => {
@ -241,6 +249,21 @@ const useUploadStatusQuery = ({
isError, isError,
setJobId: (_jobId: string) => { setJobId: (_jobId: string) => {
setJobId(_jobId) setJobId(_jobId)
setEnabled(true)
}, },
} }
} }
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}

View File

@ -181,6 +181,7 @@ export const ComposePost = observer(function ComposePost({
clearVideo, clearVideo,
state: videoUploadState, state: videoUploadState,
updateVideoDimensions, updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({ } = useUploadVideo({
setStatus: setProcessingState, setStatus: setProcessingState,
onSuccess: () => { onSuccess: () => {
@ -313,8 +314,8 @@ export const ComposePost = observer(function ComposePost({
if ( if (
!finishedUploading && !finishedUploading &&
videoUploadState.status !== 'idle' && videoUploadState.asset &&
videoUploadState.asset videoUploadState.status !== 'done'
) { ) {
setPublishOnUpload(true) setPublishOnUpload(true)
return return
@ -607,7 +608,7 @@ export const ComposePost = observer(function ComposePost({
</Text> </Text>
</View> </View>
)} )}
{error !== '' && ( {(error !== '' || videoUploadState.error) && (
<View style={[a.px_lg, a.pb_sm]}> <View style={[a.px_lg, a.pb_sm]}>
<View <View
style={[ style={[
@ -623,7 +624,7 @@ export const ComposePost = observer(function ComposePost({
]}> ]}>
<CircleInfo fill={t.palette.negative_400} /> <CircleInfo fill={t.palette.negative_400} />
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}> <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
{error} {error || videoUploadState.error}
</NewText> </NewText>
<Button <Button
label={_(msg`Dismiss error`)} label={_(msg`Dismiss error`)}
@ -638,7 +639,10 @@ export const ComposePost = observer(function ComposePost({
right: a.px_md.paddingRight, right: a.px_md.paddingRight,
}, },
]} ]}
onPress={() => setError('')}> onPress={() => {
if (error) setError('')
else videoUploadDispatch({type: 'Reset'})
}}>
<ButtonIcon icon={X} /> <ButtonIcon icon={X} />
</Button> </Button>
</View> </View>
@ -755,7 +759,8 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium, t.atoms.border_contrast_medium,
styles.bottomBar, styles.bottomBar,
]}> ]}>
{videoUploadState.status !== 'idle' ? ( {videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' ? (
<VideoUploadToolbar state={videoUploadState} /> <VideoUploadToolbar state={videoUploadState} />
) : ( ) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({
<SelectVideoBtn <SelectVideoBtn
onSelectVideo={selectVideo} onSelectVideo={selectVideo}
disabled={!canSelectImages} disabled={!canSelectImages}
setError={setError}
/> />
)} )}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
@ -1032,15 +1038,33 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) { function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui()
let text = ''
switch (state.status) {
case 'compressing':
text = _('Compressing video...')
break
case 'uploading':
text = _('Uploading video...')
break
case 'processing':
text = _('Processing video...')
break
case 'done':
text = _('Video uploaded')
break
}
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
const progress = const progress =
state.status === 'compressing' || state.status === 'uploading' state.status === 'compressing' || state.status === 'uploading'
? state.progress ? state.progress
: state.jobStatus?.progress ?? 100 : 100
return ( return (
<ToolbarWrapper <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle <ProgressCircle
size={30} size={30}
borderWidth={1} borderWidth={1}
@ -1048,7 +1072,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
color={t.palette.primary_500} color={t.palette.primary_500}
progress={progress} progress={progress}
/> />
<Text>{state.status}</Text> <NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
</ToolbarWrapper> </ToolbarWrapper>
) )
} }

View File

@ -19,9 +19,10 @@ const VIDEO_MAX_DURATION = 90
type Props = { type Props = {
onSelectVideo: (video: ImagePickerAsset) => void onSelectVideo: (video: ImagePickerAsset) => void
disabled?: boolean disabled?: boolean
setError: (error: string) => void
} }
export function SelectVideoBtn({onSelectVideo, disabled}: Props) { export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
@ -41,9 +42,17 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current, UIImagePickerPreferredAssetRepresentationMode.Current,
}) })
if (response.assets && response.assets.length > 0) { if (response.assets && response.assets.length > 0) {
onSelectVideo(response.assets[0]) try {
onSelectVideo(response.assets[0])
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError(_(msg`An error occurred while selecting the video`))
}
}
} }
}, [onSelectVideo, requestVideoAccessIfNeeded]) }, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
return ( return (
<> <>

View File

@ -59,7 +59,7 @@ export function VideoPreview({
<video <video
ref={ref} ref={ref}
src={video.uri} src={video.uri}
style={a.flex_1} style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay autoPlay
loop loop
muted muted