diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 709f2a77..79c58f5d 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 c0870253..c071b33a 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 701a7e23..a06a239e 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 ba007005..ae873d75 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 db58b60c..898f1736 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 6fdd9d5b..23e04316 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 c3ad3926..40f58645 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 3c5094c7..87f31564 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 8a8fa66b..e42e23ba 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({ )} - {error !== '' && ( + {(error !== '' || videoUploadState.error) && ( - {error} + {error || videoUploadState.error} @@ -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' ? ( ) : ( @@ -764,6 +769,7 @@ export const ComposePost = observer(function ComposePost({ )} @@ -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 ( - + - {state.status} + {text} ) } diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 9c528a92..d8accd06 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 5e7f8285..e802addd 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({