diff --git a/app.config.js b/app.config.js index 9616805231..7826b3f3a2 100644 --- a/app.config.js +++ b/app.config.js @@ -202,7 +202,7 @@ module.exports = function (config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], - ['@config-plugins/ffmpeg-kit-react-native', {package: 'video'}], + ['@config-plugins/ffmpeg-kit-react-native', {package: 'min-gpl'}], './plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidStylesWindowBackgroundPlugin.js', diff --git a/assets/icons/videoClip_stroke2_corner0_rounded.svg b/assets/icons/videoClip_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..fd4c08d478 --- /dev/null +++ b/assets/icons/videoClip_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/icons/VideoClip.tsx b/src/components/icons/VideoClip.tsx new file mode 100644 index 0000000000..c2c13c4913 --- /dev/null +++ b/src/components/icons/VideoClip.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z', +}) diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts new file mode 100644 index 0000000000..e9b306d2b5 --- /dev/null +++ b/src/lib/media/video/compress.ts @@ -0,0 +1,16 @@ +import {FFmpegKit, ReturnCode} from 'ffmpeg-kit-react-native' + +const PRESET = 'faster' + +export async function compressVideo(file: string) { + const ext = file.split('.').pop() + const newFile = file.replace(`.${ext}`, 'compressed.mp4') + const result = await FFmpegKit.execute( + `-i ${file} -c:v libx264 -crf 28 -preset ${PRESET} -b:v 1M -vf "scale=-1:720" -t 90 -c:a aac -b:a 128k -movflags +faststart ${newFile}`, + ) + + return { + uri: ReturnCode.isSuccess(await result.getReturnCode()) ? newFile : null, + session: result, + } +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 80bce5351c..820c20f087 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -91,6 +91,9 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import hairlineWidth = StyleSheet.hairlineWidth +import {SelectVideoBtn} from './videos/SelectVideoBtn' +import {useVideoState} from './videos/state' +import {VideoPreview} from './videos/VideoPreview' type CancelRef = { onPressCancel: () => void @@ -150,6 +153,9 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState( initQuote, ) + const {video, onSelectVideo, setVideoPending, videoPending} = useVideoState({ + setError, + }) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -358,8 +364,8 @@ export const ComposePost = observer(function ComposePost({ ? _(msg`Write your reply`) : _(msg`What's up?`) - const canSelectImages = gallery.size < 4 && !extLink - const hasMedia = gallery.size > 0 || Boolean(extLink) + const canSelectImages = gallery.size < 4 && !extLink && !video + const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -583,7 +589,8 @@ export const ComposePost = observer(function ComposePost({ setQuote(undefined)} /> )} - ) : undefined} + ) : null} + {video && } @@ -602,6 +609,12 @@ export const ComposePost = observer(function ComposePost({ ]}> + void + disabled?: boolean + pending: boolean + setPending: (pending: boolean) => void +} + +export function SelectVideoBtn({ + onSelectVideo, + disabled, + pending, + setPending, +}: Props) { + const {_} = useLingui() + const t = useTheme() + + const onPressSelectVideo = useCallback(async () => { + try { + setPending(true) + const response = await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Videos, + videoMaxDuration: VIDEO_MAX_DURATION, + quality: 1, + }) + if (response.assets && response.assets.length > 0) { + onSelectVideo(response.assets[0]) + } + } finally { + setPending(false) + } + }, [onSelectVideo, setPending]) + + return ( + <> + + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx new file mode 100644 index 0000000000..1adf0750b1 --- /dev/null +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import * as FileSystem from 'expo-file-system' + +import {Text} from '#/components/Typography' + +export function VideoPreview({video}: {video: FileSystem.FileInfo}) { + return {JSON.stringify(video, null, 2)} +} diff --git a/src/view/com/composer/videos/state.ts b/src/view/com/composer/videos/state.ts new file mode 100644 index 0000000000..2298d3d59e --- /dev/null +++ b/src/view/com/composer/videos/state.ts @@ -0,0 +1,50 @@ +import {useState} from 'react' +import * as FileSystem from 'expo-file-system' +import {ImagePickerAsset} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {compressVideo} from '#/lib/media/video/compress' + +export function useVideoState({setError}: {setError: (error: string) => void}) { + const [pending, setVideoPending] = useState(false) + const {_} = useLingui() + + const {mutate, data, isPending, isError, reset} = useMutation({ + mutationFn: async (asset: ImagePickerAsset) => { + const compressed = await compressVideo(asset.uri) + if (!compressed.uri) { + throw new Error('Failed to compress video') + } + + const res = await FileSystem.getInfoAsync(compressed.uri, {size: true}) + if (res.exists) { + console.log( + 'uncompressed size', + (asset.fileSize! / 1024 / 1024).toFixed(2) + 'mb', + ) + console.log( + 'compressed size', + (res.size / 1024 / 1024).toFixed(2) + 'mb', + ) + return res + } else { + throw new Error('Could not find output video') + } + }, + onError: error => { + console.error('error', error) + setError(_(msg`Could not compress video`)) + }, + }) + + return { + video: data, + onSelectVideo: mutate, + videoPending: pending || isPending, + setVideoPending, + videoError: isError, + clearVideo: reset, + } +}