Bluesky app fork with some witchin' additions 💫

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

* surface errors in UI

* style progress indicator

* remove job status progress

* rm log

* fix webm ext

authored by samuel.fm and committed by

GitHub 0e1de199 f9d73665

+155 -60
+2 -1
src/lib/media/video/compress.ts
··· 29 ) 30 31 const info = await getVideoMetaData(compressed) 32 - return {uri: compressed, size: info.size} 33 }
··· 29 ) 30 31 const info = await getVideoMetaData(compressed) 32 + 33 + return {uri: compressed, size: info.size, mimeType: `video/${info.extension}`} 34 }
+1
src/lib/media/video/compress.web.ts
··· 23 size: blob.size, 24 uri, 25 bytes: await blob.arrayBuffer(), 26 } 27 } 28
··· 23 size: blob.size, 24 uri, 25 bytes: await blob.arrayBuffer(), 26 + mimeType, 27 } 28 } 29
+7
src/lib/media/video/errors.ts
··· 4 this.name = 'VideoTooLargeError' 5 } 6 }
··· 4 this.name = 'VideoTooLargeError' 5 } 6 } 7 + 8 + export class ServerError extends Error { 9 + constructor(message: string) { 10 + super(message) 11 + this.name = 'ServerError' 12 + } 13 + }
+1
src/lib/media/video/types.ts
··· 1 export type CompressedVideo = { 2 uri: string 3 size: number 4 // web only, can fall back to uri if missing 5 bytes?: ArrayBuffer
··· 1 export type CompressedVideo = { 2 uri: string 3 + mimeType: string 4 size: number 5 // web only, can fall back to uri if missing 6 bytes?: ArrayBuffer
+13
src/state/queries/video/util.ts
··· 24 }) 25 }, []) 26 }
··· 24 }) 25 }, []) 26 } 27 + 28 + export function mimeToExt(mimeType: string) { 29 + switch (mimeType) { 30 + case 'video/mp4': 31 + return 'mp4' 32 + case 'video/webm': 33 + return 'webm' 34 + case 'video/mpeg': 35 + return 'mpeg' 36 + default: 37 + throw new Error(`Unsupported mime type: ${mimeType}`) 38 + } 39 + }
+14 -3
src/state/queries/video/video-upload.ts
··· 1 import {createUploadTask, FileSystemUploadType} from 'expo-file-system' 2 import {AppBskyVideoDefs} from '@atproto/api' 3 import {useMutation} from '@tanstack/react-query' 4 import {nanoid} from 'nanoid/non-secure' 5 6 import {cancelable} from '#/lib/async/cancelable' 7 import {CompressedVideo} from '#/lib/media/video/types' 8 - import {createVideoEndpointUrl} from '#/state/queries/video/util' 9 import {useAgent, useSession} from '#/state/session' 10 import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' 11 ··· 22 }) => { 23 const {currentAccount} = useSession() 24 const agent = useAgent() 25 26 return useMutation({ 27 mutationKey: ['video', 'upload'], 28 mutationFn: cancelable(async (video: CompressedVideo) => { 29 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 30 did: currentAccount!.did, 31 - name: `${nanoid(12)}.mp4`, 32 }) 33 34 const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) ··· 50 video.uri, 51 { 52 headers: { 53 - 'content-type': 'video/mp4', 54 Authorization: `Bearer ${serviceAuth.token}`, 55 }, 56 httpMethod: 'POST', ··· 65 } 66 67 const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 68 return responseBody 69 }, signal), 70 onError,
··· 1 import {createUploadTask, FileSystemUploadType} from 'expo-file-system' 2 import {AppBskyVideoDefs} from '@atproto/api' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 import {useMutation} from '@tanstack/react-query' 6 import {nanoid} from 'nanoid/non-secure' 7 8 import {cancelable} from '#/lib/async/cancelable' 9 + import {ServerError} from '#/lib/media/video/errors' 10 import {CompressedVideo} from '#/lib/media/video/types' 11 + import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' 12 import {useAgent, useSession} from '#/state/session' 13 import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' 14 ··· 25 }) => { 26 const {currentAccount} = useSession() 27 const agent = useAgent() 28 + const {_} = useLingui() 29 30 return useMutation({ 31 mutationKey: ['video', 'upload'], 32 mutationFn: cancelable(async (video: CompressedVideo) => { 33 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 34 did: currentAccount!.did, 35 + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 36 }) 37 38 const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) ··· 54 video.uri, 55 { 56 headers: { 57 + 'content-type': video.mimeType, 58 Authorization: `Bearer ${serviceAuth.token}`, 59 }, 60 httpMethod: 'POST', ··· 69 } 70 71 const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 72 + 73 + if (!responseBody.jobId) { 74 + throw new ServerError( 75 + responseBody.error || _(msg`Failed to upload video`), 76 + ) 77 + } 78 + 79 return responseBody 80 }, signal), 81 onError,
+13 -8
src/state/queries/video/video-upload.web.ts
··· 1 import {AppBskyVideoDefs} from '@atproto/api' 2 import {useMutation} from '@tanstack/react-query' 3 import {nanoid} from 'nanoid/non-secure' 4 5 import {cancelable} from '#/lib/async/cancelable' 6 import {CompressedVideo} from '#/lib/media/video/types' 7 - import {createVideoEndpointUrl} from '#/state/queries/video/util' 8 import {useAgent, useSession} from '#/state/session' 9 import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' 10 ··· 21 }) => { 22 const {currentAccount} = useSession() 23 const agent = useAgent() 24 25 return useMutation({ 26 mutationKey: ['video', 'upload'], 27 mutationFn: cancelable(async (video: CompressedVideo) => { 28 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 29 did: currentAccount!.did, 30 - name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' 31 }) 32 33 const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) ··· 63 xhr.responseText, 64 ) as AppBskyVideoDefs.JobStatus 65 resolve(uploadRes) 66 - onSuccess(uploadRes) 67 } else { 68 - reject() 69 - onError(new Error('Failed to upload video')) 70 } 71 } 72 xhr.onerror = () => { 73 - reject() 74 - onError(new Error('Failed to upload video')) 75 } 76 xhr.open('POST', uri) 77 - xhr.setRequestHeader('Content-Type', 'video/mp4') 78 xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) 79 xhr.send(bytes) 80 }, 81 ) 82 83 return res 84 }, signal),
··· 1 import {AppBskyVideoDefs} from '@atproto/api' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 import {useMutation} from '@tanstack/react-query' 5 import {nanoid} from 'nanoid/non-secure' 6 7 import {cancelable} from '#/lib/async/cancelable' 8 + import {ServerError} from '#/lib/media/video/errors' 9 import {CompressedVideo} from '#/lib/media/video/types' 10 + import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' 11 import {useAgent, useSession} from '#/state/session' 12 import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' 13 ··· 24 }) => { 25 const {currentAccount} = useSession() 26 const agent = useAgent() 27 + const {_} = useLingui() 28 29 return useMutation({ 30 mutationKey: ['video', 'upload'], 31 mutationFn: cancelable(async (video: CompressedVideo) => { 32 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 33 did: currentAccount!.did, 34 + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 35 }) 36 37 const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl) ··· 67 xhr.responseText, 68 ) as AppBskyVideoDefs.JobStatus 69 resolve(uploadRes) 70 } else { 71 + reject(new ServerError(_(msg`Failed to upload video`))) 72 } 73 } 74 xhr.onerror = () => { 75 + reject(new ServerError(_(msg`Failed to upload video`))) 76 } 77 xhr.open('POST', uri) 78 + xhr.setRequestHeader('Content-Type', video.mimeType) 79 xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) 80 xhr.send(bytes) 81 }, 82 ) 83 + 84 + if (!res.jobId) { 85 + throw new ServerError(res.error || _(msg`Failed to upload video`)) 86 + } 87 88 return res 89 }, signal),
+57 -34
src/state/queries/video/video.ts
··· 6 import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 7 8 import {logger} from '#/logger' 9 - import {VideoTooLargeError} from 'lib/media/video/errors' 10 import {CompressedVideo} from 'lib/media/video/types' 11 import {useCompressVideoMutation} from 'state/queries/video/compress-video' 12 import {useVideoAgent} from 'state/queries/video/util' ··· 58 abortController: new AbortController(), 59 } 60 } else if (action.type === 'SetAsset') { 61 - updatedState = {...state, asset: action.asset} 62 } else if (action.type === 'SetDimensions') { 63 updatedState = { 64 ...state, ··· 67 : undefined, 68 } 69 } else if (action.type === 'SetVideo') { 70 - updatedState = {...state, video: action.video} 71 } else if (action.type === 'SetJobStatus') { 72 updatedState = {...state, jobStatus: action.jobStatus} 73 } else if (action.type === 'SetBlobRef') { 74 - updatedState = {...state, blobRef: action.blobRef} 75 } 76 return updatedState 77 } ··· 108 type: 'SetBlobRef', 109 blobRef, 110 }) 111 - dispatch({ 112 - type: 'SetStatus', 113 - status: 'idle', 114 - }) 115 onSuccess() 116 }, 117 }) ··· 125 setJobId(response.jobId) 126 }, 127 onError: e => { 128 - dispatch({ 129 - type: 'SetError', 130 - error: _(msg`An error occurred while uploading the video.`), 131 - }) 132 logger.error('Error uploading video', {safeMessage: e}) 133 }, 134 setProgress: p => { ··· 141 onProgress: p => { 142 dispatch({type: 'SetProgress', progress: p}) 143 }, 144 onError: e => { 145 if (e instanceof VideoTooLargeError) { 146 dispatch({ ··· 150 } else { 151 dispatch({ 152 type: 'SetError', 153 - // @TODO better error message from server, left untranslated on purpose 154 - error: 'An error occurred while compressing the video.', 155 }) 156 logger.error('Error compressing video', {safeMessage: e}) 157 } 158 }, 159 - onSuccess: (video: CompressedVideo) => { 160 - dispatch({ 161 - type: 'SetVideo', 162 - video, 163 - }) 164 - dispatch({ 165 - type: 'SetStatus', 166 - status: 'uploading', 167 - }) 168 - onVideoCompressed(video) 169 - }, 170 signal: state.abortController.signal, 171 }) 172 173 const selectVideo = (asset: ImagePickerAsset) => { 174 - dispatch({ 175 - type: 'SetAsset', 176 - asset, 177 - }) 178 - dispatch({ 179 - type: 'SetStatus', 180 - status: 'compressing', 181 - }) 182 - onSelectVideo(asset) 183 } 184 185 const clearVideo = () => { ··· 241 isError, 242 setJobId: (_jobId: string) => { 243 setJobId(_jobId) 244 }, 245 } 246 }
··· 6 import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 7 8 import {logger} from '#/logger' 9 + import {isWeb} from '#/platform/detection' 10 + import {ServerError, VideoTooLargeError} from 'lib/media/video/errors' 11 import {CompressedVideo} from 'lib/media/video/types' 12 import {useCompressVideoMutation} from 'state/queries/video/compress-video' 13 import {useVideoAgent} from 'state/queries/video/util' ··· 59 abortController: new AbortController(), 60 } 61 } else if (action.type === 'SetAsset') { 62 + updatedState = { 63 + ...state, 64 + asset: action.asset, 65 + status: 'compressing', 66 + error: undefined, 67 + } 68 } else if (action.type === 'SetDimensions') { 69 updatedState = { 70 ...state, ··· 73 : undefined, 74 } 75 } else if (action.type === 'SetVideo') { 76 + updatedState = {...state, video: action.video, status: 'uploading'} 77 } else if (action.type === 'SetJobStatus') { 78 updatedState = {...state, jobStatus: action.jobStatus} 79 } else if (action.type === 'SetBlobRef') { 80 + updatedState = {...state, blobRef: action.blobRef, status: 'done'} 81 } 82 return updatedState 83 } ··· 114 type: 'SetBlobRef', 115 blobRef, 116 }) 117 onSuccess() 118 }, 119 }) ··· 127 setJobId(response.jobId) 128 }, 129 onError: e => { 130 + if (e instanceof ServerError) { 131 + dispatch({ 132 + type: 'SetError', 133 + error: e.message, 134 + }) 135 + } else { 136 + dispatch({ 137 + type: 'SetError', 138 + error: _(msg`An error occurred while uploading the video.`), 139 + }) 140 + } 141 logger.error('Error uploading video', {safeMessage: e}) 142 }, 143 setProgress: p => { ··· 150 onProgress: p => { 151 dispatch({type: 'SetProgress', progress: p}) 152 }, 153 + onSuccess: (video: CompressedVideo) => { 154 + dispatch({ 155 + type: 'SetVideo', 156 + video, 157 + }) 158 + onVideoCompressed(video) 159 + }, 160 onError: e => { 161 if (e instanceof VideoTooLargeError) { 162 dispatch({ ··· 166 } else { 167 dispatch({ 168 type: 'SetError', 169 + error: _(msg`An error occurred while compressing the video.`), 170 }) 171 logger.error('Error compressing video', {safeMessage: e}) 172 } 173 }, 174 signal: state.abortController.signal, 175 }) 176 177 const selectVideo = (asset: ImagePickerAsset) => { 178 + switch (getMimeType(asset)) { 179 + case 'video/mp4': 180 + case 'video/mpeg': 181 + case 'video/webm': 182 + dispatch({ 183 + type: 'SetAsset', 184 + asset, 185 + }) 186 + onSelectVideo(asset) 187 + break 188 + default: 189 + throw new Error(_(msg`Unsupported video type: ${getMimeType(asset)}`)) 190 + } 191 } 192 193 const clearVideo = () => { ··· 249 isError, 250 setJobId: (_jobId: string) => { 251 setJobId(_jobId) 252 + setEnabled(true) 253 }, 254 } 255 } 256 + 257 + function getMimeType(asset: ImagePickerAsset) { 258 + if (isWeb) { 259 + const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') 260 + if (!mimeType) { 261 + throw new Error('Could not determine mime type') 262 + } 263 + return mimeType 264 + } 265 + if (!asset.mimeType) { 266 + throw new Error('Could not determine mime type') 267 + } 268 + return asset.mimeType 269 + }
+34 -10
src/view/com/composer/Composer.tsx
··· 181 clearVideo, 182 state: videoUploadState, 183 updateVideoDimensions, 184 } = useUploadVideo({ 185 setStatus: setProcessingState, 186 onSuccess: () => { ··· 313 314 if ( 315 !finishedUploading && 316 - videoUploadState.status !== 'idle' && 317 - videoUploadState.asset 318 ) { 319 setPublishOnUpload(true) 320 return ··· 607 </Text> 608 </View> 609 )} 610 - {error !== '' && ( 611 <View style={[a.px_lg, a.pb_sm]}> 612 <View 613 style={[ ··· 623 ]}> 624 <CircleInfo fill={t.palette.negative_400} /> 625 <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}> 626 - {error} 627 </NewText> 628 <Button 629 label={_(msg`Dismiss error`)} ··· 638 right: a.px_md.paddingRight, 639 }, 640 ]} 641 - onPress={() => setError('')}> 642 <ButtonIcon icon={X} /> 643 </Button> 644 </View> ··· 755 t.atoms.border_contrast_medium, 756 styles.bottomBar, 757 ]}> 758 - {videoUploadState.status !== 'idle' ? ( 759 <VideoUploadToolbar state={videoUploadState} /> 760 ) : ( 761 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> ··· 764 <SelectVideoBtn 765 onSelectVideo={selectVideo} 766 disabled={!canSelectImages} 767 /> 768 )} 769 <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> ··· 1032 1033 function VideoUploadToolbar({state}: {state: VideoUploadState}) { 1034 const t = useTheme() 1035 1036 const progress = 1037 state.status === 'compressing' || state.status === 'uploading' 1038 ? state.progress 1039 - : state.jobStatus?.progress ?? 100 1040 1041 return ( 1042 - <ToolbarWrapper 1043 - style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}> 1044 <ProgressCircle 1045 size={30} 1046 borderWidth={1} ··· 1048 color={t.palette.primary_500} 1049 progress={progress} 1050 /> 1051 - <Text>{state.status}</Text> 1052 </ToolbarWrapper> 1053 ) 1054 }
··· 181 clearVideo, 182 state: videoUploadState, 183 updateVideoDimensions, 184 + dispatch: videoUploadDispatch, 185 } = useUploadVideo({ 186 setStatus: setProcessingState, 187 onSuccess: () => { ··· 314 315 if ( 316 !finishedUploading && 317 + videoUploadState.asset && 318 + videoUploadState.status !== 'done' 319 ) { 320 setPublishOnUpload(true) 321 return ··· 608 </Text> 609 </View> 610 )} 611 + {(error !== '' || videoUploadState.error) && ( 612 <View style={[a.px_lg, a.pb_sm]}> 613 <View 614 style={[ ··· 624 ]}> 625 <CircleInfo fill={t.palette.negative_400} /> 626 <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}> 627 + {error || videoUploadState.error} 628 </NewText> 629 <Button 630 label={_(msg`Dismiss error`)} ··· 639 right: a.px_md.paddingRight, 640 }, 641 ]} 642 + onPress={() => { 643 + if (error) setError('') 644 + else videoUploadDispatch({type: 'Reset'}) 645 + }}> 646 <ButtonIcon icon={X} /> 647 </Button> 648 </View> ··· 759 t.atoms.border_contrast_medium, 760 styles.bottomBar, 761 ]}> 762 + {videoUploadState.status !== 'idle' && 763 + videoUploadState.status !== 'done' ? ( 764 <VideoUploadToolbar state={videoUploadState} /> 765 ) : ( 766 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> ··· 769 <SelectVideoBtn 770 onSelectVideo={selectVideo} 771 disabled={!canSelectImages} 772 + setError={setError} 773 /> 774 )} 775 <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> ··· 1038 1039 function VideoUploadToolbar({state}: {state: VideoUploadState}) { 1040 const t = useTheme() 1041 + const {_} = useLingui() 1042 1043 + let text = '' 1044 + 1045 + switch (state.status) { 1046 + case 'compressing': 1047 + text = _('Compressing video...') 1048 + break 1049 + case 'uploading': 1050 + text = _('Uploading video...') 1051 + break 1052 + case 'processing': 1053 + text = _('Processing video...') 1054 + break 1055 + case 'done': 1056 + text = _('Video uploaded') 1057 + break 1058 + } 1059 + 1060 + // we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100 1061 const progress = 1062 state.status === 'compressing' || state.status === 'uploading' 1063 ? state.progress 1064 + : 100 1065 1066 return ( 1067 + <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> 1068 <ProgressCircle 1069 size={30} 1070 borderWidth={1} ··· 1072 color={t.palette.primary_500} 1073 progress={progress} 1074 /> 1075 + <NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText> 1076 </ToolbarWrapper> 1077 ) 1078 }
+12 -3
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 19 type Props = { 20 onSelectVideo: (video: ImagePickerAsset) => void 21 disabled?: boolean 22 } 23 24 - export function SelectVideoBtn({onSelectVideo, disabled}: Props) { 25 const {_} = useLingui() 26 const t = useTheme() 27 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() ··· 41 UIImagePickerPreferredAssetRepresentationMode.Current, 42 }) 43 if (response.assets && response.assets.length > 0) { 44 - onSelectVideo(response.assets[0]) 45 } 46 - }, [onSelectVideo, requestVideoAccessIfNeeded]) 47 48 return ( 49 <>
··· 19 type Props = { 20 onSelectVideo: (video: ImagePickerAsset) => void 21 disabled?: boolean 22 + setError: (error: string) => void 23 } 24 25 + export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { 26 const {_} = useLingui() 27 const t = useTheme() 28 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() ··· 42 UIImagePickerPreferredAssetRepresentationMode.Current, 43 }) 44 if (response.assets && response.assets.length > 0) { 45 + try { 46 + onSelectVideo(response.assets[0]) 47 + } catch (err) { 48 + if (err instanceof Error) { 49 + setError(err.message) 50 + } else { 51 + setError(_(msg`An error occurred while selecting the video`)) 52 + } 53 + } 54 } 55 + }, [onSelectVideo, requestVideoAccessIfNeeded, setError, _]) 56 57 return ( 58 <>
+1 -1
src/view/com/composer/videos/VideoPreview.web.tsx
··· 59 <video 60 ref={ref} 61 src={video.uri} 62 - style={a.flex_1} 63 autoPlay 64 loop 65 muted
··· 59 <video 60 ref={ref} 61 src={video.uri} 62 + style={{width: '100%', height: '100%', objectFit: 'cover'}} 63 autoPlay 64 loop 65 muted