about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/media/video/compress.ts3
-rw-r--r--src/lib/media/video/compress.web.ts1
-rw-r--r--src/lib/media/video/errors.ts7
-rw-r--r--src/lib/media/video/types.ts1
-rw-r--r--src/state/queries/video/util.ts13
-rw-r--r--src/state/queries/video/video-upload.ts17
-rw-r--r--src/state/queries/video/video-upload.web.ts21
-rw-r--r--src/state/queries/video/video.ts91
-rw-r--r--src/view/com/composer/Composer.tsx44
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx15
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx2
11 files changed, 155 insertions, 60 deletions
diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts
index 709f2a77a..79c58f5dd 100644
--- a/src/lib/media/video/compress.ts
+++ b/src/lib/media/video/compress.ts
@@ -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}`}
 }
diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts
index c08702534..c071b33ae 100644
--- a/src/lib/media/video/compress.web.ts
+++ b/src/lib/media/video/compress.web.ts
@@ -23,6 +23,7 @@ export async function compressVideo(
     size: blob.size,
     uri,
     bytes: await blob.arrayBuffer(),
+    mimeType,
   }
 }
 
diff --git a/src/lib/media/video/errors.ts b/src/lib/media/video/errors.ts
index 701a7e235..a06a239e1 100644
--- a/src/lib/media/video/errors.ts
+++ b/src/lib/media/video/errors.ts
@@ -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'
+  }
+}
diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts
index ba0070054..ae873d756 100644
--- a/src/lib/media/video/types.ts
+++ b/src/lib/media/video/types.ts
@@ -1,5 +1,6 @@
 export type CompressedVideo = {
   uri: string
+  mimeType: string
   size: number
   // web only, can fall back to uri if missing
   bytes?: ArrayBuffer
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
index db58b60c1..898f1736d 100644
--- a/src/state/queries/video/util.ts
+++ b/src/state/queries/video/util.ts
@@ -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}`)
+  }
+}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
index 6fdd9d5bb..23e04316e 100644
--- a/src/state/queries/video/video-upload.ts
+++ b/src/state/queries/video/video-upload.ts
@@ -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'
 
@@ -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)
@@ -50,7 +54,7 @@ export const useUploadVideoMutation = ({
         video.uri,
         {
           headers: {
-            'content-type': 'video/mp4',
+            'content-type': video.mimeType,
             Authorization: `Bearer ${serviceAuth.token}`,
           },
           httpMethod: 'POST',
@@ -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,
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
index c3ad39268..40f586450 100644
--- a/src/state/queries/video/video-upload.web.ts
+++ b/src/state/queries/video/video-upload.web.ts
@@ -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'
 
@@ -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)
@@ -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,
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
index 3c5094c71..87f315640 100644
--- a/src/state/queries/video/video.ts
+++ b/src/state/queries/video/video.ts
@@ -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'
@@ -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,
@@ -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
   }
@@ -108,10 +114,6 @@ export function useUploadVideo({
         type: 'SetBlobRef',
         blobRef,
       })
-      dispatch({
-        type: 'SetStatus',
-        status: 'idle',
-      })
       onSuccess()
     },
   })
@@ -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 => {
@@ -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({
@@ -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 = () => {
@@ -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
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 8a8fa66b7..e42e23ba5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -181,6 +181,7 @@ export const ComposePost = observer(function ComposePost({
     clearVideo,
     state: videoUploadState,
     updateVideoDimensions,
+    dispatch: videoUploadDispatch,
   } = useUploadVideo({
     setStatus: setProcessingState,
     onSuccess: () => {
@@ -313,8 +314,8 @@ export const ComposePost = observer(function ComposePost({
 
     if (
       !finishedUploading &&
-      videoUploadState.status !== 'idle' &&
-      videoUploadState.asset
+      videoUploadState.asset &&
+      videoUploadState.status !== 'done'
     ) {
       setPublishOnUpload(true)
       return
@@ -607,7 +608,7 @@ export const ComposePost = observer(function ComposePost({
               </Text>
             </View>
           )}
-          {error !== '' && (
+          {(error !== '' || videoUploadState.error) && (
             <View style={[a.px_lg, a.pb_sm]}>
               <View
                 style={[
@@ -623,7 +624,7 @@ export const ComposePost = observer(function ComposePost({
                 ]}>
                 <CircleInfo fill={t.palette.negative_400} />
                 <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
-                  {error}
+                  {error || videoUploadState.error}
                 </NewText>
                 <Button
                   label={_(msg`Dismiss error`)}
@@ -638,7 +639,10 @@ export const ComposePost = observer(function ComposePost({
                       right: a.px_md.paddingRight,
                     },
                   ]}
-                  onPress={() => setError('')}>
+                  onPress={() => {
+                    if (error) setError('')
+                    else videoUploadDispatch({type: 'Reset'})
+                  }}>
                   <ButtonIcon icon={X} />
                 </Button>
               </View>
@@ -755,7 +759,8 @@ export const ComposePost = observer(function ComposePost({
             t.atoms.border_contrast_medium,
             styles.bottomBar,
           ]}>
-          {videoUploadState.status !== 'idle' ? (
+          {videoUploadState.status !== 'idle' &&
+          videoUploadState.status !== 'done' ? (
             <VideoUploadToolbar state={videoUploadState} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
@@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({
                 <SelectVideoBtn
                   onSelectVideo={selectVideo}
                   disabled={!canSelectImages}
+                  setError={setError}
                 />
               )}
               <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
@@ -1032,15 +1038,33 @@ function ToolbarWrapper({
 
 function VideoUploadToolbar({state}: {state: VideoUploadState}) {
   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 =
     state.status === 'compressing' || state.status === 'uploading'
       ? state.progress
-      : state.jobStatus?.progress ?? 100
+      : 100
 
   return (
-    <ToolbarWrapper
-      style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
+    <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
       <ProgressCircle
         size={30}
         borderWidth={1}
@@ -1048,7 +1072,7 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
         color={t.palette.primary_500}
         progress={progress}
       />
-      <Text>{state.status}</Text>
+      <NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
     </ToolbarWrapper>
   )
 }
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 9c528a92e..d8accd062 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -19,9 +19,10 @@ const VIDEO_MAX_DURATION = 90
 type Props = {
   onSelectVideo: (video: ImagePickerAsset) => void
   disabled?: boolean
+  setError: (error: string) => void
 }
 
-export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
+export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
   const {_} = useLingui()
   const t = useTheme()
   const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
@@ -41,9 +42,17 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
         UIImagePickerPreferredAssetRepresentationMode.Current,
     })
     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 (
     <>
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index 5e7f82857..e802adddf 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -59,7 +59,7 @@ export function VideoPreview({
       <video
         ref={ref}
         src={video.uri}
-        style={a.flex_1}
+        style={{width: '100%', height: '100%', objectFit: 'cover'}}
         autoPlay
         loop
         muted