add progress component
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user