Skip to content

Commit

Permalink
[Video] Upload errors and UI improvements (#5092)
Browse files Browse the repository at this point in the history
* surface errors in UI

* style progress indicator

* remove job status progress

* rm log

* fix webm ext
  • Loading branch information
mozzius authored Sep 3, 2024
1 parent f9d7366 commit 0e1de19
Show file tree
Hide file tree
Showing 11 changed files with 155 additions and 60 deletions.
3 changes: 2 additions & 1 deletion src/lib/media/video/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ export async function compressVideo(
)

const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}

return {uri: compressed, size: info.size, mimeType: `video/${info.extension}`}
}
1 change: 1 addition & 0 deletions src/lib/media/video/compress.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function compressVideo(
size: blob.size,
uri,
bytes: await blob.arrayBuffer(),
mimeType,
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/lib/media/video/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ export class VideoTooLargeError extends Error {
this.name = 'VideoTooLargeError'
}
}

export class ServerError extends Error {
constructor(message: string) {
super(message)
this.name = 'ServerError'
}
}
1 change: 1 addition & 0 deletions src/lib/media/video/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type CompressedVideo = {
uri: string
mimeType: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
Expand Down
13 changes: 13 additions & 0 deletions src/state/queries/video/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
}
17 changes: 14 additions & 3 deletions src/state/queries/video/video-upload.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'

import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
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 {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'

Expand All @@ -22,13 +25,14 @@ export const useUploadVideoMutation = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {_} = useLingui()

return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.mp4`,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})

const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
Expand All @@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
video.uri,
{
headers: {
'content-type': 'video/mp4',
'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`,
},
httpMethod: 'POST',
Expand All @@ -65,6 +69,13 @@ export const useUploadVideoMutation = ({
}

const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus

if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || _(msg`Failed to upload video`),
)
}

return responseBody
}, signal),
onError,
Expand Down
21 changes: 13 additions & 8 deletions src/state/queries/video/video-upload.web.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'

import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
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 {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'

Expand All @@ -21,13 +24,14 @@ export const useUploadVideoMutation = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {_} = useLingui()

return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
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)
Expand Down Expand Up @@ -63,23 +67,24 @@ export const useUploadVideoMutation = ({
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
onSuccess(uploadRes)
} else {
reject()
onError(new Error('Failed to upload video'))
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject()
onError(new Error('Failed to upload video'))
reject(new ServerError(_(msg`Failed to upload video`)))
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', 'video/mp4')
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.send(bytes)
},
)

if (!res.jobId) {
throw new ServerError(res.error || _(msg`Failed to upload video`))
}

return res
}, signal),
onError,
Expand Down
91 changes: 57 additions & 34 deletions src/state/queries/video/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'

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 {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util'
Expand Down Expand Up @@ -58,7 +59,12 @@ function reducer(queryClient: QueryClient) {
abortController: new AbortController(),
}
} else if (action.type === 'SetAsset') {
updatedState = {...state, asset: action.asset}
updatedState = {
...state,
asset: action.asset,
status: 'compressing',
error: undefined,
}
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
Expand All @@ -67,11 +73,11 @@ function reducer(queryClient: QueryClient) {
: undefined,
}
} else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video}
updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetBlobRef') {
updatedState = {...state, blobRef: action.blobRef}
updatedState = {...state, blobRef: action.blobRef, status: 'done'}
}
return updatedState
}
Expand Down Expand Up @@ -108,10 +114,6 @@ export function useUploadVideo({
type: 'SetBlobRef',
blobRef,
})
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess()
},
})
Expand All @@ -125,10 +127,17 @@ export function useUploadVideo({
setJobId(response.jobId)
},
onError: e => {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
if (e instanceof ServerError) {
dispatch({
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})
},
setProgress: p => {
Expand All @@ -141,6 +150,13 @@ export function useUploadVideo({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof VideoTooLargeError) {
dispatch({
Expand All @@ -150,36 +166,28 @@ export function useUploadVideo({
} else {
dispatch({
type: 'SetError',
// @TODO better error message from server, left untranslated on purpose
error: 'An error occurred while compressing the video.',
error: _(msg`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)
},
signal: state.abortController.signal,
})

const selectVideo = (asset: ImagePickerAsset) => {
dispatch({
type: 'SetAsset',
asset,
})
dispatch({
type: 'SetStatus',
status: 'compressing',
})
onSelectVideo(asset)
switch (getMimeType(asset)) {
case 'video/mp4':
case 'video/mpeg':
case 'video/webm':
dispatch({
type: 'SetAsset',
asset,
})
onSelectVideo(asset)
break
default:
throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`))
}
}

const clearVideo = () => {
Expand Down Expand Up @@ -241,6 +249,21 @@ const useUploadStatusQuery = ({
isError,
setJobId: (_jobId: string) => {
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
}
Loading

0 comments on commit 0e1de19

Please sign in to comment.