my fork of the bluesky client

[Video] Uploads (#4754)

* state for video uploads

* get upload working

* add a debug log

* add post progress

* progress

* fetch data

* add some progress info, web uploads

* post on finished uploading (wip)

* add a note

* add some todos

* clear video

* merge some stuff

* convert to `createUploadTask`

* patch expo modules core

* working native upload progress

* platform fork

* upload progress for web

* cleanup

* cleanup

* more tweaks

* simplify

* fix type errors

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

authored by hailey.at

Samuel Newman and committed by
GitHub
8ddb28d3 43ba0f21

+594 -112
+12
patches/expo-modules-core+1.12.11.patch
··· 12 12 13 13 Map<String, Object> constants = new HashMap<>(3); 14 14 constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); 15 + diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js 16 + index 109d3fe..c7fce9e 100644 17 + --- a/node_modules/expo-modules-core/build/uuid/uuid.js 18 + +++ b/node_modules/expo-modules-core/build/uuid/uuid.js 19 + @@ -1,5 +1,7 @@ 20 + import bytesToUuid from './lib/bytesToUuid'; 21 + import { Uuidv5Namespace } from './uuid.types'; 22 + +import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; 23 + +ensureNativeModulesAreInstalled(); 24 + const nativeUuidv4 = globalThis?.expo?.uuidv4; 25 + const nativeUuidv5 = globalThis?.expo?.uuidv5; 26 + function uuidv4() {
+4
src/lib/api/index.ts
··· 54 54 uri: string 55 55 cid: string 56 56 } 57 + video?: { 58 + uri: string 59 + cid: string 60 + } 57 61 extLink?: ExternalEmbedDraft 58 62 images?: ImageModel[] 59 63 labels?: string[]
+36
src/lib/media/video/types.ts
··· 1 + /** 2 + * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. 3 + * @temporary 4 + * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. 5 + * Not joking, this is temporary. 6 + */ 7 + 8 + export interface JobStatus { 9 + jobId: string 10 + did: string 11 + cid: string 12 + state: JobState 13 + progress?: number 14 + errorHuman?: string 15 + errorMachine?: string 16 + } 17 + 18 + export enum JobState { 19 + JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', 20 + JOB_STATE_CREATED = 'JOB_STATE_CREATED', 21 + JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', 22 + JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', 23 + JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', 24 + JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', 25 + JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', 26 + JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', 27 + JOB_STATE_FAILED = 'JOB_STATE_FAILED', 28 + JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', 29 + } 30 + 31 + export interface UploadVideoResponse { 32 + job_id: string 33 + did: string 34 + cid: string 35 + state: JobState 36 + }
+31
src/state/queries/video/compress-video.ts
··· 1 + import {ImagePickerAsset} from 'expo-image-picker' 2 + import {useMutation} from '@tanstack/react-query' 3 + 4 + import {CompressedVideo, compressVideo} from 'lib/media/video/compress' 5 + 6 + export function useCompressVideoMutation({ 7 + onProgress, 8 + onSuccess, 9 + onError, 10 + }: { 11 + onProgress: (progress: number) => void 12 + onError: (e: any) => void 13 + onSuccess: (video: CompressedVideo) => void 14 + }) { 15 + return useMutation({ 16 + mutationFn: async (asset: ImagePickerAsset) => { 17 + return await compressVideo(asset.uri, { 18 + onProgress: num => onProgress(trunc2dp(num)), 19 + }) 20 + }, 21 + onError, 22 + onSuccess, 23 + onMutate: () => { 24 + onProgress(0) 25 + }, 26 + }) 27 + } 28 + 29 + function trunc2dp(num: number) { 30 + return Math.trunc(num * 100) / 100 31 + }
+15
src/state/queries/video/util.ts
··· 1 + const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' 2 + 3 + export const createVideoEndpointUrl = ( 4 + route: string, 5 + params?: Record<string, string>, 6 + ) => { 7 + const url = new URL(`${UPLOAD_ENDPOINT}`) 8 + url.pathname = route 9 + if (params) { 10 + for (const key in params) { 11 + url.searchParams.set(key, params[key]) 12 + } 13 + } 14 + return url.href 15 + }
+59
src/state/queries/video/video-upload.ts
··· 1 + import {createUploadTask, FileSystemUploadType} from 'expo-file-system' 2 + import {useMutation} from '@tanstack/react-query' 3 + import {nanoid} from 'nanoid/non-secure' 4 + 5 + import {CompressedVideo} from 'lib/media/video/compress' 6 + import {UploadVideoResponse} from 'lib/media/video/types' 7 + import {createVideoEndpointUrl} from 'state/queries/video/util' 8 + import {useSession} from 'state/session' 9 + const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' 10 + 11 + export const useUploadVideoMutation = ({ 12 + onSuccess, 13 + onError, 14 + setProgress, 15 + }: { 16 + onSuccess: (response: UploadVideoResponse) => void 17 + onError: (e: any) => void 18 + setProgress: (progress: number) => void 19 + }) => { 20 + const {currentAccount} = useSession() 21 + 22 + return useMutation({ 23 + mutationFn: async (video: CompressedVideo) => { 24 + const uri = createVideoEndpointUrl('/upload', { 25 + did: currentAccount!.did, 26 + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? 27 + }) 28 + 29 + const uploadTask = createUploadTask( 30 + uri, 31 + video.uri, 32 + { 33 + headers: { 34 + 'dev-key': UPLOAD_HEADER, 35 + 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? 36 + }, 37 + httpMethod: 'POST', 38 + uploadType: FileSystemUploadType.BINARY_CONTENT, 39 + }, 40 + p => { 41 + setProgress(p.totalBytesSent / p.totalBytesExpectedToSend) 42 + }, 43 + ) 44 + const res = await uploadTask.uploadAsync() 45 + 46 + if (!res?.body) { 47 + throw new Error('No response') 48 + } 49 + 50 + // @TODO rm, useful for debugging/getting video cid 51 + console.log('[VIDEO]', res.body) 52 + const responseBody = JSON.parse(res.body) as UploadVideoResponse 53 + onSuccess(responseBody) 54 + return responseBody 55 + }, 56 + onError, 57 + onSuccess, 58 + }) 59 + }
+66
src/state/queries/video/video-upload.web.ts
··· 1 + import {useMutation} from '@tanstack/react-query' 2 + import {nanoid} from 'nanoid/non-secure' 3 + 4 + import {CompressedVideo} from 'lib/media/video/compress' 5 + import {UploadVideoResponse} from 'lib/media/video/types' 6 + import {createVideoEndpointUrl} from 'state/queries/video/util' 7 + import {useSession} from 'state/session' 8 + const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' 9 + 10 + export const useUploadVideoMutation = ({ 11 + onSuccess, 12 + onError, 13 + setProgress, 14 + }: { 15 + onSuccess: (response: UploadVideoResponse) => void 16 + onError: (e: any) => void 17 + setProgress: (progress: number) => void 18 + }) => { 19 + const {currentAccount} = useSession() 20 + 21 + return useMutation({ 22 + mutationFn: async (video: CompressedVideo) => { 23 + const uri = createVideoEndpointUrl('/upload', { 24 + did: currentAccount!.did, 25 + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? 26 + }) 27 + 28 + const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) 29 + 30 + const xhr = new XMLHttpRequest() 31 + const res = (await new Promise((resolve, reject) => { 32 + xhr.upload.addEventListener('progress', e => { 33 + const progress = e.loaded / e.total 34 + setProgress(progress) 35 + }) 36 + xhr.onloadend = () => { 37 + if (xhr.readyState === 4) { 38 + const uploadRes = JSON.parse( 39 + xhr.responseText, 40 + ) as UploadVideoResponse 41 + resolve(uploadRes) 42 + onSuccess(uploadRes) 43 + } else { 44 + reject() 45 + onError(new Error('Failed to upload video')) 46 + } 47 + } 48 + xhr.onerror = () => { 49 + reject() 50 + onError(new Error('Failed to upload video')) 51 + } 52 + xhr.open('POST', uri) 53 + xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? 54 + // @TODO remove this header for prod 55 + xhr.setRequestHeader('dev-key', UPLOAD_HEADER) 56 + xhr.send(bytes) 57 + })) as UploadVideoResponse 58 + 59 + // @TODO rm for prod 60 + console.log('[VIDEO]', res) 61 + return res 62 + }, 63 + onError, 64 + onSuccess, 65 + }) 66 + }
+212
src/state/queries/video/video.ts
··· 1 + import React from 'react' 2 + import {ImagePickerAsset} from 'expo-image-picker' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useQuery} from '@tanstack/react-query' 6 + 7 + import {logger} from '#/logger' 8 + import {CompressedVideo} from 'lib/media/video/compress' 9 + import {VideoTooLargeError} from 'lib/media/video/errors' 10 + import {JobState, JobStatus} from 'lib/media/video/types' 11 + import {useCompressVideoMutation} from 'state/queries/video/compress-video' 12 + import {createVideoEndpointUrl} from 'state/queries/video/util' 13 + import {useUploadVideoMutation} from 'state/queries/video/video-upload' 14 + 15 + type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' 16 + 17 + type Action = 18 + | { 19 + type: 'SetStatus' 20 + status: Status 21 + } 22 + | { 23 + type: 'SetProgress' 24 + progress: number 25 + } 26 + | { 27 + type: 'SetError' 28 + error: string | undefined 29 + } 30 + | {type: 'Reset'} 31 + | {type: 'SetAsset'; asset: ImagePickerAsset} 32 + | {type: 'SetVideo'; video: CompressedVideo} 33 + | {type: 'SetJobStatus'; jobStatus: JobStatus} 34 + 35 + export interface State { 36 + status: Status 37 + progress: number 38 + asset?: ImagePickerAsset 39 + video: CompressedVideo | null 40 + jobStatus?: JobStatus 41 + error?: string 42 + } 43 + 44 + function reducer(state: State, action: Action): State { 45 + let updatedState = state 46 + if (action.type === 'SetStatus') { 47 + updatedState = {...state, status: action.status} 48 + } else if (action.type === 'SetProgress') { 49 + updatedState = {...state, progress: action.progress} 50 + } else if (action.type === 'SetError') { 51 + updatedState = {...state, error: action.error} 52 + } else if (action.type === 'Reset') { 53 + updatedState = { 54 + status: 'idle', 55 + progress: 0, 56 + video: null, 57 + } 58 + } else if (action.type === 'SetAsset') { 59 + updatedState = {...state, asset: action.asset} 60 + } else if (action.type === 'SetVideo') { 61 + updatedState = {...state, video: action.video} 62 + } else if (action.type === 'SetJobStatus') { 63 + updatedState = {...state, jobStatus: action.jobStatus} 64 + } 65 + return updatedState 66 + } 67 + 68 + export function useUploadVideo({ 69 + setStatus, 70 + onSuccess, 71 + }: { 72 + setStatus: (status: string) => void 73 + onSuccess: () => void 74 + }) { 75 + const {_} = useLingui() 76 + const [state, dispatch] = React.useReducer(reducer, { 77 + status: 'idle', 78 + progress: 0, 79 + video: null, 80 + }) 81 + 82 + const {setJobId} = useUploadStatusQuery({ 83 + onStatusChange: (status: JobStatus) => { 84 + // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user 85 + // Leaving it for now though 86 + dispatch({ 87 + type: 'SetJobStatus', 88 + jobStatus: status, 89 + }) 90 + setStatus(status.state.toString()) 91 + }, 92 + onSuccess: () => { 93 + dispatch({ 94 + type: 'SetStatus', 95 + status: 'idle', 96 + }) 97 + onSuccess() 98 + }, 99 + }) 100 + 101 + const {mutate: onVideoCompressed} = useUploadVideoMutation({ 102 + onSuccess: response => { 103 + dispatch({ 104 + type: 'SetStatus', 105 + status: 'processing', 106 + }) 107 + setJobId(response.job_id) 108 + }, 109 + onError: e => { 110 + dispatch({ 111 + type: 'SetError', 112 + error: _(msg`An error occurred while uploading the video.`), 113 + }) 114 + logger.error('Error uploading video', {safeMessage: e}) 115 + }, 116 + setProgress: p => { 117 + dispatch({type: 'SetProgress', progress: p}) 118 + }, 119 + }) 120 + 121 + const {mutate: onSelectVideo} = useCompressVideoMutation({ 122 + onProgress: p => { 123 + dispatch({type: 'SetProgress', progress: p}) 124 + }, 125 + onError: e => { 126 + if (e instanceof VideoTooLargeError) { 127 + dispatch({ 128 + type: 'SetError', 129 + error: _(msg`The selected video is larger than 100MB.`), 130 + }) 131 + } else { 132 + dispatch({ 133 + type: 'SetError', 134 + // @TODO better error message from server, left untranslated on purpose 135 + error: 'An error occurred while compressing the video.', 136 + }) 137 + logger.error('Error compressing video', {safeMessage: e}) 138 + } 139 + }, 140 + onSuccess: (video: CompressedVideo) => { 141 + dispatch({ 142 + type: 'SetVideo', 143 + video, 144 + }) 145 + dispatch({ 146 + type: 'SetStatus', 147 + status: 'uploading', 148 + }) 149 + onVideoCompressed(video) 150 + }, 151 + }) 152 + 153 + const selectVideo = (asset: ImagePickerAsset) => { 154 + dispatch({ 155 + type: 'SetAsset', 156 + asset, 157 + }) 158 + dispatch({ 159 + type: 'SetStatus', 160 + status: 'compressing', 161 + }) 162 + onSelectVideo(asset) 163 + } 164 + 165 + const clearVideo = () => { 166 + // @TODO cancel any running jobs 167 + dispatch({type: 'Reset'}) 168 + } 169 + 170 + return { 171 + state, 172 + dispatch, 173 + selectVideo, 174 + clearVideo, 175 + } 176 + } 177 + 178 + const useUploadStatusQuery = ({ 179 + onStatusChange, 180 + onSuccess, 181 + }: { 182 + onStatusChange: (status: JobStatus) => void 183 + onSuccess: () => void 184 + }) => { 185 + const [enabled, setEnabled] = React.useState(true) 186 + const [jobId, setJobId] = React.useState<string>() 187 + 188 + const {isLoading, isError} = useQuery({ 189 + queryKey: ['video-upload'], 190 + queryFn: async () => { 191 + const url = createVideoEndpointUrl(`/job/${jobId}/status`) 192 + const res = await fetch(url) 193 + const status = (await res.json()) as JobStatus 194 + if (status.state === JobState.JOB_STATE_COMPLETED) { 195 + setEnabled(false) 196 + onSuccess() 197 + } 198 + onStatusChange(status) 199 + return status 200 + }, 201 + enabled: Boolean(jobId && enabled), 202 + refetchInterval: 1500, 203 + }) 204 + 205 + return { 206 + isLoading, 207 + isError, 208 + setJobId: (_jobId: string) => { 209 + setJobId(_jobId) 210 + }, 211 + } 212 + }
+18
src/state/shell/post-progress.tsx
··· 1 + import React from 'react' 2 + 3 + interface PostProgressState { 4 + progress: number 5 + status: 'pending' | 'success' | 'error' | 'idle' 6 + error?: string 7 + } 8 + 9 + const PostProgressContext = React.createContext<PostProgressState>({ 10 + progress: 0, 11 + status: 'idle', 12 + }) 13 + 14 + export function Provider() {} 15 + 16 + export function usePostProgress() { 17 + return React.useContext(PostProgressContext) 18 + }
+136 -57
src/view/com/composer/Composer.tsx
··· 13 13 Keyboard, 14 14 KeyboardAvoidingView, 15 15 LayoutChangeEvent, 16 + StyleProp, 16 17 StyleSheet, 17 18 View, 19 + ViewStyle, 18 20 } from 'react-native' 21 + // @ts-expect-error no type definition 22 + import ProgressCircle from 'react-native-progress/Circle' 19 23 import Animated, { 24 + FadeIn, 25 + FadeOut, 20 26 interpolateColor, 21 27 useAnimatedStyle, 22 28 useSharedValue, ··· 55 61 import {useProfileQuery} from '#/state/queries/profile' 56 62 import {Gif} from '#/state/queries/tenor' 57 63 import {ThreadgateSetting} from '#/state/queries/threadgate' 64 + import {useUploadVideo} from '#/state/queries/video/video' 58 65 import {useAgent, useSession} from '#/state/session' 59 66 import {useComposerControls} from '#/state/shell/composer' 60 67 import {useAnalytics} from 'lib/analytics/analytics' ··· 70 77 import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' 71 78 import {useDialogStateControlContext} from 'state/dialogs' 72 79 import {GalleryModel} from 'state/models/media/gallery' 80 + import {State as VideoUploadState} from 'state/queries/video/video' 73 81 import {ComposerOpts} from 'state/shell/composer' 74 82 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' 75 83 import {atoms as a, useTheme} from '#/alf' ··· 96 104 import {ThreadgateBtn} from './threadgate/ThreadgateBtn' 97 105 import {useExternalLinkFetch} from './useExternalLinkFetch' 98 106 import {SelectVideoBtn} from './videos/SelectVideoBtn' 99 - import {useVideoState} from './videos/state' 100 107 import {VideoPreview} from './videos/VideoPreview' 101 108 import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' 102 109 ··· 159 166 const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( 160 167 initQuote, 161 168 ) 169 + 162 170 const { 163 - video, 164 - onSelectVideo, 165 - videoPending, 166 - videoProcessingData, 171 + selectVideo, 167 172 clearVideo, 168 - videoProcessingProgress, 169 - } = useVideoState({setError}) 173 + state: videoUploadState, 174 + } = useUploadVideo({ 175 + setStatus: (status: string) => setProcessingState(status), 176 + onSuccess: () => { 177 + if (publishOnUpload) { 178 + onPressPublish(true) 179 + } 180 + }, 181 + }) 182 + const [publishOnUpload, setPublishOnUpload] = useState(false) 183 + 170 184 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 171 185 const [extGif, setExtGif] = useState<Gif>() 172 186 const [labels, setLabels] = useState<string[]>([]) ··· 274 288 return false 275 289 }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) 276 290 277 - const onPressPublish = async () => { 291 + const onPressPublish = async (finishedUploading?: boolean) => { 278 292 if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { 279 293 return 280 294 } ··· 283 297 return 284 298 } 285 299 300 + if ( 301 + !finishedUploading && 302 + videoUploadState.status !== 'idle' && 303 + videoUploadState.asset 304 + ) { 305 + setPublishOnUpload(true) 306 + return 307 + } 308 + 286 309 setError('') 287 310 288 311 if ( ··· 387 410 : _(msg`What's up?`) 388 411 389 412 const canSelectImages = 390 - gallery.size < 4 && !extLink && !video && !videoPending 391 - const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) 413 + gallery.size < 4 && 414 + !extLink && 415 + videoUploadState.status === 'idle' && 416 + !videoUploadState.video 417 + const hasMedia = 418 + gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) 392 419 393 420 const onEmojiButtonPress = useCallback(() => { 394 421 openPicker?.(textInput.current?.getCursorPosition()) ··· 500 527 shape="default" 501 528 size="small" 502 529 style={[a.rounded_full, a.py_sm]} 503 - onPress={onPressPublish}> 530 + onPress={() => onPressPublish()} 531 + disabled={ 532 + videoUploadState.status !== 'idle' && publishOnUpload 533 + }> 504 534 <ButtonText style={[a.text_md]}> 505 535 {replyTo ? ( 506 536 <Trans context="action">Reply</Trans> ··· 572 602 autoFocus 573 603 setRichText={setRichText} 574 604 onPhotoPasted={onPhotoPasted} 575 - onPressPublish={onPressPublish} 605 + onPressPublish={() => onPressPublish()} 576 606 onNewLink={onNewLink} 577 607 onError={setError} 578 608 accessible={true} ··· 602 632 </View> 603 633 )} 604 634 605 - {quote ? ( 606 - <View style={[s.mt5, s.mb2, isWeb && s.mb10]}> 607 - <View style={{pointerEvents: 'none'}}> 608 - <QuoteEmbed quote={quote} /> 635 + <View style={[a.mt_md]}> 636 + {quote ? ( 637 + <View style={[s.mt5, s.mb2, isWeb && s.mb10]}> 638 + <View style={{pointerEvents: 'none'}}> 639 + <QuoteEmbed quote={quote} /> 640 + </View> 641 + {quote.uri !== initQuote?.uri && ( 642 + <QuoteX onRemove={() => setQuote(undefined)} /> 643 + )} 609 644 </View> 610 - {quote.uri !== initQuote?.uri && ( 611 - <QuoteX onRemove={() => setQuote(undefined)} /> 612 - )} 613 - </View> 614 - ) : null} 615 - {videoPending && videoProcessingData ? ( 616 - <VideoTranscodeProgress 617 - input={videoProcessingData} 618 - progress={videoProcessingProgress} 619 - /> 620 - ) : ( 621 - video && ( 645 + ) : null} 646 + {videoUploadState.status === 'compressing' && 647 + videoUploadState.asset ? ( 648 + <VideoTranscodeProgress 649 + asset={videoUploadState.asset} 650 + progress={videoUploadState.progress} 651 + /> 652 + ) : videoUploadState.video ? ( 622 653 // remove suspense when we get rid of lazy 623 654 <Suspense fallback={null}> 624 - <VideoPreview video={video} clear={clearVideo} /> 655 + <VideoPreview 656 + video={videoUploadState.video} 657 + clear={clearVideo} 658 + /> 625 659 </Suspense> 626 - ) 627 - )} 660 + ) : null} 661 + </View> 628 662 </Animated.ScrollView> 629 663 <SuggestedLanguage text={richtext.text} /> 630 664 ··· 641 675 t.atoms.border_contrast_medium, 642 676 styles.bottomBar, 643 677 ]}> 644 - <View style={[a.flex_row, a.align_center, a.gap_xs]}> 645 - <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> 646 - {gate('videos') && ( 647 - <SelectVideoBtn 648 - onSelectVideo={onSelectVideo} 649 - disabled={!canSelectImages} 678 + {videoUploadState.status !== 'idle' ? ( 679 + <VideoUploadToolbar state={videoUploadState} /> 680 + ) : ( 681 + <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> 682 + <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> 683 + {gate('videos') && ( 684 + <SelectVideoBtn 685 + onSelectVideo={selectVideo} 686 + disabled={!canSelectImages} 687 + /> 688 + )} 689 + <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> 690 + <SelectGifBtn 691 + onClose={focusTextInput} 692 + onSelectGif={onSelectGif} 693 + disabled={hasMedia} 650 694 /> 651 - )} 652 - <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> 653 - <SelectGifBtn 654 - onClose={focusTextInput} 655 - onSelectGif={onSelectGif} 656 - disabled={hasMedia} 657 - /> 658 - {!isMobile ? ( 659 - <Button 660 - onPress={onEmojiButtonPress} 661 - style={a.p_sm} 662 - label={_(msg`Open emoji picker`)} 663 - accessibilityHint={_(msg`Open emoji picker`)} 664 - variant="ghost" 665 - shape="round" 666 - color="primary"> 667 - <EmojiSmile size="lg" /> 668 - </Button> 669 - ) : null} 670 - </View> 695 + {!isMobile ? ( 696 + <Button 697 + onPress={onEmojiButtonPress} 698 + style={a.p_sm} 699 + label={_(msg`Open emoji picker`)} 700 + accessibilityHint={_(msg`Open emoji picker`)} 701 + variant="ghost" 702 + shape="round" 703 + color="primary"> 704 + <EmojiSmile size="lg" /> 705 + </Button> 706 + ) : null} 707 + </ToolbarWrapper> 708 + )} 671 709 <View style={a.flex_1} /> 672 710 <SelectLangBtn /> 673 711 <CharProgress count={graphemeLength} /> ··· 893 931 borderTopWidth: StyleSheet.hairlineWidth, 894 932 }, 895 933 }) 934 + 935 + function ToolbarWrapper({ 936 + style, 937 + children, 938 + }: { 939 + style: StyleProp<ViewStyle> 940 + children: React.ReactNode 941 + }) { 942 + if (isWeb) return children 943 + return ( 944 + <Animated.View 945 + style={style} 946 + entering={FadeIn.duration(400)} 947 + exiting={FadeOut.duration(400)}> 948 + {children} 949 + </Animated.View> 950 + ) 951 + } 952 + 953 + function VideoUploadToolbar({state}: {state: VideoUploadState}) { 954 + const t = useTheme() 955 + 956 + const progress = 957 + state.status === 'compressing' || state.status === 'uploading' 958 + ? state.progress 959 + : state.jobStatus?.progress ?? 100 960 + 961 + return ( 962 + <ToolbarWrapper 963 + style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}> 964 + <ProgressCircle 965 + size={30} 966 + borderWidth={1} 967 + borderColor={t.atoms.border_contrast_low.borderColor} 968 + color={t.palette.primary_500} 969 + progress={progress} 970 + /> 971 + <Text>{state.status}</Text> 972 + </ToolbarWrapper> 973 + ) 974 + }
+1
src/view/com/composer/videos/VideoPreview.tsx
··· 17 17 const player = useVideoPlayer(video.uri, player => { 18 18 player.loop = true 19 19 player.play() 20 + player.volume = 0 20 21 }) 21 22 22 23 return (
+4 -4
src/view/com/composer/videos/VideoTranscodeProgress.tsx
··· 9 9 import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' 10 10 11 11 export function VideoTranscodeProgress({ 12 - input, 12 + asset, 13 13 progress, 14 14 }: { 15 - input: ImagePickerAsset 15 + asset: ImagePickerAsset 16 16 progress: number 17 17 }) { 18 18 const t = useTheme() 19 19 20 - const aspectRatio = input.width / input.height 20 + const aspectRatio = asset.width / asset.height 21 21 22 22 return ( 23 23 <View ··· 29 29 a.overflow_hidden, 30 30 {aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio}, 31 31 ]}> 32 - <VideoTranscodeBackdrop uri={input.uri} /> 32 + <VideoTranscodeBackdrop uri={asset.uri} /> 33 33 <View 34 34 style={[ 35 35 a.flex_1,
-51
src/view/com/composer/videos/state.ts
··· 1 - import {useState} from 'react' 2 - import {ImagePickerAsset} from 'expo-image-picker' 3 - import {msg} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {useMutation} from '@tanstack/react-query' 6 - 7 - import {compressVideo} from '#/lib/media/video/compress' 8 - import {logger} from '#/logger' 9 - import {VideoTooLargeError} from 'lib/media/video/errors' 10 - import * as Toast from 'view/com/util/Toast' 11 - 12 - export function useVideoState({setError}: {setError: (error: string) => void}) { 13 - const {_} = useLingui() 14 - const [progress, setProgress] = useState(0) 15 - 16 - const {mutate, data, isPending, isError, reset, variables} = useMutation({ 17 - mutationFn: async (asset: ImagePickerAsset) => { 18 - const compressed = await compressVideo(asset.uri, { 19 - onProgress: num => setProgress(trunc2dp(num)), 20 - }) 21 - 22 - return compressed 23 - }, 24 - onError: (e: any) => { 25 - // Don't log these errors in sentry, just let the user know 26 - if (e instanceof VideoTooLargeError) { 27 - Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark') 28 - return 29 - } 30 - logger.error('Failed to compress video', {safeError: e}) 31 - setError(_(msg`Could not compress video`)) 32 - }, 33 - onMutate: () => { 34 - setProgress(0) 35 - }, 36 - }) 37 - 38 - return { 39 - video: data, 40 - onSelectVideo: mutate, 41 - videoPending: isPending, 42 - videoProcessingData: variables, 43 - videoError: isError, 44 - clearVideo: reset, 45 - videoProcessingProgress: progress, 46 - } 47 - } 48 - 49 - function trunc2dp(num: number) { 50 - return Math.trunc(num * 100) / 100 51 - }