add progress component

This commit is contained in:
Samuel Newman
2024-06-06 12:14:13 +03:00
parent 1412d2a544
commit 6a54959080
7 changed files with 155 additions and 47 deletions
+33 -6
View File
@@ -1,16 +1,43 @@
import {FFmpegKit, ReturnCode} from 'ffmpeg-kit-react-native' import * as FileSystem from 'expo-file-system'
import {
FFmpegKit,
FFmpegSessionCompleteCallback,
LogCallback,
ReturnCode,
StatisticsCallback,
} from 'ffmpeg-kit-react-native'
const PRESET = 'faster' const PRESET = 'faster'
export async function compressVideo(file: string) { export async function compressVideo(
file: string,
callbacks?: {
onLog?: LogCallback
onStatistics?: StatisticsCallback
},
) {
const {onLog, onStatistics} = callbacks || {}
const ext = file.split('.').pop() const ext = file.split('.').pop()
const newFile = file.replace(`.${ext}`, 'compressed.mp4') const newFile = file.replace(`.${ext}`, '.compressed.mp4')
const result = await FFmpegKit.execute(
`-i ${file} -c:v libx264 -crf 28 -preset ${PRESET} -b:v 4M -vf "scale='if(gt(a,1),min(1920,iw),-1)':'if(gt(a,1),-1,min(1920,ih))'" -t 90 -c:a aac -b:a 128k -movflags +faststart ${newFile}`, const result = await new Promise((resolve: FFmpegSessionCompleteCallback) =>
FFmpegKit.executeAsync(
`-i ${file} -c:v libx264 -crf 25 -preset ${PRESET} -b:v 4M -vf "scale='if(gt(a,1),min(1920,iw),-1)':'if(gt(a,1),-1,min(1920,ih))'" -t 90 -c:a aac -b:a 320k -movflags +faststart ${newFile}`,
resolve,
onLog,
onStatistics,
),
) )
const success = ReturnCode.isSuccess(await result.getReturnCode())
if (success) {
await FileSystem.deleteAsync(file)
}
return { return {
uri: ReturnCode.isSuccess(await result.getReturnCode()) ? newFile : null, uri: success ? newFile : null,
session: result, session: result,
} }
} }
+19 -7
View File
@@ -94,6 +94,7 @@ import hairlineWidth = StyleSheet.hairlineWidth
import {SelectVideoBtn} from './videos/SelectVideoBtn' import {SelectVideoBtn} from './videos/SelectVideoBtn'
import {useVideoState} from './videos/state' import {useVideoState} from './videos/state'
import {VideoPreview} from './videos/VideoPreview' import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
type CancelRef = { type CancelRef = {
onPressCancel: () => void onPressCancel: () => void
@@ -153,9 +154,14 @@ export const ComposePost = observer(function ComposePost({
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote, initQuote,
) )
const {video, onSelectVideo, setVideoPending, videoPending} = useVideoState({ const {
setError, video,
}) onSelectVideo,
videoPending,
videoProcessingData,
clearVideo,
videoProcessingProgress,
} = useVideoState({setError})
const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>() const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
@@ -364,7 +370,8 @@ export const ComposePost = observer(function ComposePost({
? _(msg`Write your reply`) ? _(msg`Write your reply`)
: _(msg`What's up?`) : _(msg`What's up?`)
const canSelectImages = gallery.size < 4 && !extLink && !video const canSelectImages =
gallery.size < 4 && !extLink && !video && !videoPending
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
const onEmojiButtonPress = useCallback(() => { const onEmojiButtonPress = useCallback(() => {
@@ -590,7 +597,14 @@ export const ComposePost = observer(function ComposePost({
)} )}
</View> </View>
) : null} ) : null}
{video && <VideoPreview video={video} />} {videoPending && videoProcessingData ? (
<VideoTranscodeProgress
input={videoProcessingData}
progress={videoProcessingProgress}
/>
) : (
video && <VideoPreview video={video} clear={clearVideo} />
)}
</Animated.ScrollView> </Animated.ScrollView>
<SuggestedLanguage text={richtext.text} /> <SuggestedLanguage text={richtext.text} />
@@ -612,8 +626,6 @@ export const ComposePost = observer(function ComposePost({
<SelectVideoBtn <SelectVideoBtn
onSelectVideo={onSelectVideo} onSelectVideo={onSelectVideo}
disabled={!canSelectImages} disabled={!canSelectImages}
pending={videoPending}
setPending={setVideoPending}
/> />
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn <SelectGifBtn
@@ -1,13 +1,14 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Text} from '../../util/text/Text'
// @ts-ignore no type definition -prf // @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle' import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf // @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie' import ProgressPie from 'react-native-progress/Pie'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants' import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {Text} from '../../util/text/Text'
const DANGER_LENGTH = MAX_GRAPHEME_LENGTH const DANGER_LENGTH = MAX_GRAPHEME_LENGTH
+16 -24
View File
@@ -3,6 +3,7 @@ import {
ImagePickerAsset, ImagePickerAsset,
launchImageLibraryAsync, launchImageLibraryAsync,
MediaTypeOptions, MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker' } from 'expo-image-picker'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -16,35 +17,26 @@ const VIDEO_MAX_DURATION = 90
type Props = { type Props = {
onSelectVideo: (video: ImagePickerAsset) => void onSelectVideo: (video: ImagePickerAsset) => void
disabled?: boolean disabled?: boolean
pending: boolean
setPending: (pending: boolean) => void
} }
export function SelectVideoBtn({ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
onSelectVideo,
disabled,
pending,
setPending,
}: Props) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const onPressSelectVideo = useCallback(async () => { const onPressSelectVideo = useCallback(async () => {
try { const response = await launchImageLibraryAsync({
setPending(true) exif: false,
const response = await launchImageLibraryAsync({ mediaTypes: MediaTypeOptions.Videos,
exif: false, videoMaxDuration: VIDEO_MAX_DURATION,
mediaTypes: MediaTypeOptions.Videos, quality: 1,
videoMaxDuration: VIDEO_MAX_DURATION, legacy: true,
quality: 1, preferredAssetRepresentationMode:
}) UIImagePickerPreferredAssetRepresentationMode.Current,
if (response.assets && response.assets.length > 0) { })
onSelectVideo(response.assets[0]) if (response.assets && response.assets.length > 0) {
} onSelectVideo(response.assets[0])
} finally {
setPending(false)
} }
}, [onSelectVideo, setPending]) }, [onSelectVideo])
return ( return (
<> <>
@@ -57,10 +49,10 @@ export function SelectVideoBtn({
variant="ghost" variant="ghost"
shape="round" shape="round"
color="primary" color="primary"
disabled={disabled || pending}> disabled={disabled}>
<VideoClipIcon <VideoClipIcon
size="lg" size="lg"
style={(disabled || pending) && t.atoms.text_contrast_low} style={disabled && t.atoms.text_contrast_low}
/> />
</Button> </Button>
</> </>
+21 -2
View File
@@ -1,8 +1,27 @@
import React from 'react' import React from 'react'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function VideoPreview({video}: {video: FileSystem.FileInfo}) { export function VideoPreview({
return <Text>{JSON.stringify(video, null, 2)}</Text> video,
clear,
}: {
video: FileSystem.FileInfo
clear: () => void
}) {
return (
<>
<Text>{JSON.stringify(video, null, 2)}</Text>
<Button
onPress={clear}
label="Clear"
size="small"
color="primary"
variant="solid">
<ButtonText>Clear</ButtonText>
</Button>
</>
)
} }
@@ -0,0 +1,47 @@
import React from 'react'
import {View} from 'react-native'
// @ts-expect-error no type definition
import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function VideoTranscodeProgress({
input,
progress,
}: {
input: ImagePickerAsset
progress: number
}) {
const t = useTheme()
return (
<View style={a.mt_md}>
<View
style={[
a.flex_1,
t.atoms.bg_contrast_50,
a.rounded_md,
a.align_center,
a.justify_center,
a.gap_lg,
{aspectRatio: Math.max(input.width / input.height, 16 / 9)},
]}>
{input.duration ? (
<ProgressPie
size={64}
borderWidth={4}
borderColor={t.atoms.text.color}
color={t.atoms.text.color}
progress={progress}
/>
) : (
<Loader size="xl" />
)}
<Text>Transcoding...</Text>
</View>
</View>
)
}
+15 -5
View File
@@ -8,12 +8,18 @@ import {useMutation} from '@tanstack/react-query'
import {compressVideo} from '#/lib/media/video/compress' import {compressVideo} from '#/lib/media/video/compress'
export function useVideoState({setError}: {setError: (error: string) => void}) { export function useVideoState({setError}: {setError: (error: string) => void}) {
const [pending, setVideoPending] = useState(false)
const {_} = useLingui() const {_} = useLingui()
const [progress, setProgress] = useState(0)
const {mutate, data, isPending, isError, reset} = useMutation({ const {mutate, data, isPending, isError, reset, variables} = useMutation({
mutationFn: async (asset: ImagePickerAsset) => { mutationFn: async (asset: ImagePickerAsset) => {
const compressed = await compressVideo(asset.uri) const compressed = await compressVideo(asset.uri, {
onStatistics: async statistics => {
if (asset.duration) {
setProgress(statistics.getTime() / asset.duration)
}
},
})
if (!compressed.uri) { if (!compressed.uri) {
throw new Error('Failed to compress video') throw new Error('Failed to compress video')
} }
@@ -37,14 +43,18 @@ export function useVideoState({setError}: {setError: (error: string) => void}) {
console.error('error', error) console.error('error', error)
setError(_(msg`Could not compress video`)) setError(_(msg`Could not compress video`))
}, },
onMutate: () => {
setProgress(0)
},
}) })
return { return {
video: data, video: data,
onSelectVideo: mutate, onSelectVideo: mutate,
videoPending: pending || isPending, videoPending: isPending,
setVideoPending, videoProcessingData: variables,
videoError: isError, videoError: isError,
clearVideo: reset, clearVideo: reset,
videoProcessingProgress: progress,
} }
} }