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