Video compression in composer (#4638)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com>zio/stable
parent
56b688744e
commit
8f06ba70bb
|
@ -211,6 +211,8 @@ module.exports = function (config) {
|
|||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'expo-video',
|
||||
'react-native-compressor',
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 370 B |
|
@ -136,6 +136,7 @@
|
|||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"expo-video": "^1.1.10",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
|
@ -166,6 +167,7 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-keyed-flatten-children": "^3.0.0",
|
||||
"react-native": "0.74.1",
|
||||
"react-native-compressor": "^1.8.24",
|
||||
"react-native-date-picker": "^4.4.2",
|
||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
|
|
|
@ -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',
|
||||
})
|
|
@ -48,6 +48,35 @@ export function usePhotoLibraryPermission() {
|
|||
return {requestPhotoAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useVideoLibraryPermission() {
|
||||
const [res, requestPermission] = MediaLibrary.usePermissions({
|
||||
granularPermissions: ['video'],
|
||||
})
|
||||
const requestVideoAccessIfNeeded = async () => {
|
||||
// On the, we use <input type="file"> to produce a filepicker
|
||||
// This does not need any permission granting.
|
||||
if (isWeb) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (res?.granted) {
|
||||
return true
|
||||
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
|
||||
const {canAskAgain, granted, status} = await requestPermission()
|
||||
|
||||
if (!canAskAgain && status === 'undetermined') {
|
||||
openPermissionAlert('video library')
|
||||
}
|
||||
|
||||
return granted
|
||||
} else {
|
||||
openPermissionAlert('video library')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return {requestVideoAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useCameraPermission() {
|
||||
const [res, requestPermission] = Camera.useCameraPermissions()
|
||||
|
||||
|
|
|
@ -14,3 +14,11 @@ export function useCameraPermission() {
|
|||
|
||||
return {requestCameraAccessIfNeeded}
|
||||
}
|
||||
|
||||
export function useVideoLibraryPermission() {
|
||||
const requestVideoAccessIfNeeded = async () => {
|
||||
return true
|
||||
}
|
||||
|
||||
return {requestVideoAccessIfNeeded}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
opts?: {
|
||||
getCancellationId?: (id: string) => void
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, getCancellationId} = opts || {}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file,
|
||||
{
|
||||
getCancellationId,
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
},
|
||||
onProgress,
|
||||
)
|
||||
|
||||
const info = await getVideoMetaData(compressed)
|
||||
return {uri: compressed, size: info.size}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
|
||||
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
_callbacks?: {
|
||||
onProgress: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const blob = await fetch(file).then(res => res.blob())
|
||||
const video = URL.createObjectURL(blob)
|
||||
|
||||
if (blob.size > MAX_VIDEO_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
size: blob.size,
|
||||
uri: video,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export class VideoTooLargeError extends Error {
|
||||
constructor() {
|
||||
super('Videos cannot be larger than 100MB')
|
||||
this.name = 'VideoTooLargeError'
|
||||
}
|
||||
}
|
|
@ -11,3 +11,4 @@ export type Gate =
|
|||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'videos'
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
|
@ -42,7 +43,7 @@ import {
|
|||
} from '#/lib/gif-alt-text'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {emitPostCreated} from '#/state/events'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
|
@ -96,6 +97,10 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
|||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||
import {useVideoState} from './videos/state'
|
||||
import {VideoPreview} from './videos/VideoPreview'
|
||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
type CancelRef = {
|
||||
|
@ -115,6 +120,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
}: Props & {
|
||||
cancelRef?: React.RefObject<CancelRef>
|
||||
}) {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
|
@ -156,6 +162,14 @@ export const ComposePost = observer(function ComposePost({
|
|||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||
initQuote,
|
||||
)
|
||||
const {
|
||||
video,
|
||||
onSelectVideo,
|
||||
videoPending,
|
||||
videoProcessingData,
|
||||
clearVideo,
|
||||
videoProcessingProgress,
|
||||
} = useVideoState({setError})
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||
const [extGif, setExtGif] = useState<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
|
@ -375,8 +389,9 @@ 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 && !videoPending
|
||||
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
openPicker?.(textInput.current?.getCursorPosition())
|
||||
|
@ -600,7 +615,20 @@ export const ComposePost = observer(function ComposePost({
|
|||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
) : undefined}
|
||||
) : null}
|
||||
{videoPending && videoProcessingData ? (
|
||||
<VideoTranscodeProgress
|
||||
input={videoProcessingData}
|
||||
progress={videoProcessingProgress}
|
||||
/>
|
||||
) : (
|
||||
video && (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview video={video} clear={clearVideo} />
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
|
||||
|
@ -619,6 +647,12 @@ export const ComposePost = observer(function ComposePost({
|
|||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={onSelectVideo}
|
||||
disabled={!canSelectImages}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import React from 'react'
|
||||
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {ExternalEmbedDraft} from 'lib/api/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {Gif} from 'state/queries/tenor'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
@ -22,7 +19,6 @@ export const ExternalEmbed = ({
|
|||
gif?: Gif
|
||||
}) => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const linkInfo = React.useMemo(
|
||||
() =>
|
||||
|
@ -70,25 +66,7 @@ export const ExternalEmbed = ({
|
|||
<ExternalLinkEmbed link={linkInfo} hideAlt />
|
||||
</View>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 10,
|
||||
height: 36,
|
||||
width: 36,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onPress={onRemove}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image preview`)}
|
||||
accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
|
||||
onAccessibilityEscape={onRemove}>
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</TouchableOpacity>
|
||||
<ExternalEmbedRemoveBtn onRemove={onRemove} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react'
|
||||
import {TouchableOpacity} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
height: 36,
|
||||
width: 36,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onPress={onRemove}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image preview`)}
|
||||
accessibilityHint={_(msg`Removes the image preview`)}
|
||||
onAccessibilityEscape={onRemove}>
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Text} from '../../util/text/Text'
|
||||
// @ts-ignore no type definition -prf
|
||||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
// @ts-ignore no type definition -prf
|
||||
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 {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {Text} from '../../util/text/Text'
|
||||
|
||||
const DANGER_LENGTH = MAX_GRAPHEME_LENGTH
|
||||
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {
|
||||
ImagePickerAsset,
|
||||
launchImageLibraryAsync,
|
||||
MediaTypeOptions,
|
||||
UIImagePickerPreferredAssetRepresentationMode,
|
||||
} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
|
||||
const VIDEO_MAX_DURATION = 90
|
||||
|
||||
type Props = {
|
||||
onSelectVideo: (video: ImagePickerAsset) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||
|
||||
const onPressSelectVideo = useCallback(async () => {
|
||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Videos,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
onSelectVideo(response.assets[0])
|
||||
}
|
||||
}, [onSelectVideo, requestVideoAccessIfNeeded])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
testID="openGifBtn"
|
||||
onPress={onPressSelectVideo}
|
||||
label={_(msg`Select video`)}
|
||||
accessibilityHint={_(msg`Opens video picker`)}
|
||||
style={a.p_sm}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary"
|
||||
disabled={disabled}>
|
||||
<VideoClipIcon
|
||||
size="lg"
|
||||
style={disabled && t.atoms.text_contrast_low}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useVideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoPreview({
|
||||
video,
|
||||
clear,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
clear: () => void
|
||||
}) {
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
})
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
allowsPictureInPicture={false}
|
||||
nativeControls={false}
|
||||
/>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoPreview({
|
||||
video,
|
||||
clear,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
clear: () => void
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
<video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline />
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import {clearCache, createVideoThumbnail} from 'react-native-compressor'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
const {data: thumbnail} = useQuery({
|
||||
queryKey: ['thumbnail', uri],
|
||||
queryFn: async () => {
|
||||
return await createVideoThumbnail(uri)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCache()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Animated.View style={a.flex_1} entering={FadeIn}>
|
||||
{thumbnail && (
|
||||
<Image
|
||||
style={a.flex_1}
|
||||
source={thumbnail.path}
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
blurRadius={15}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react'
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay />
|
||||
)
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
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 {Text} from '#/components/Typography'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoTranscodeProgress({
|
||||
input,
|
||||
progress,
|
||||
}: {
|
||||
input: ImagePickerAsset
|
||||
progress: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const aspectRatio = input.width / input.height
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.mt_md,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={input.uri} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_lg,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
]}>
|
||||
<ProgressPie
|
||||
size={64}
|
||||
borderWidth={4}
|
||||
borderColor={t.atoms.text.color}
|
||||
color={t.atoms.text.color}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>Compressing...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import {useState} from 'react'
|
||||
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'
|
||||
import {logger} from '#/logger'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useVideoState({setError}: {setError: (error: string) => void}) {
|
||||
const {_} = useLingui()
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const {mutate, data, isPending, isError, reset, variables} = useMutation({
|
||||
mutationFn: async (asset: ImagePickerAsset) => {
|
||||
const compressed = await compressVideo(asset.uri, {
|
||||
onProgress: num => setProgress(trunc2dp(num)),
|
||||
})
|
||||
|
||||
return compressed
|
||||
},
|
||||
onError: (e: any) => {
|
||||
// Don't log these errors in sentry, just let the user know
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
Toast.show(_(msg`Videos cannot be larger than 100MB`))
|
||||
return
|
||||
}
|
||||
logger.error('Failed to compress video', {safeError: e})
|
||||
setError(_(msg`Could not compress video`))
|
||||
},
|
||||
onMutate: () => {
|
||||
setProgress(0)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
video: data,
|
||||
onSelectVideo: mutate,
|
||||
videoPending: isPending,
|
||||
videoProcessingData: variables,
|
||||
videoError: isError,
|
||||
clearVideo: reset,
|
||||
videoProcessingProgress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
function trunc2dp(num: number) {
|
||||
return Math.trunc(num * 100) / 100
|
||||
}
|
|
@ -57,7 +57,7 @@ export const ExternalLinkEmbed = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
||||
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}>
|
||||
<LinkWrapper link={link} onOpen={onOpen} style={style}>
|
||||
{imageUri && !embedPlayerParams ? (
|
||||
<Image
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -12302,6 +12302,11 @@ expo-updates@~0.25.14:
|
|||
ignore "^5.3.1"
|
||||
resolve-from "^5.0.0"
|
||||
|
||||
expo-video@^1.1.10:
|
||||
version "1.1.10"
|
||||
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.1.10.tgz#b47c0d40c21f401236639424bd25d70c09316b7b"
|
||||
integrity sha512-k9ecpgtwAK8Ut8enm8Jv398XkB/uVOyLLqk80M/d8pH9EN5CVrBQ7iEzWlR3quvVUFM7Uf5wRukJ4hk3mZ8NCg==
|
||||
|
||||
expo-web-browser@~13.0.3:
|
||||
version "13.0.3"
|
||||
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"
|
||||
|
@ -18847,6 +18852,11 @@ react-keyed-flatten-children@^3.0.0:
|
|||
dependencies:
|
||||
react-is "^18.2.0"
|
||||
|
||||
react-native-compressor@^1.8.24:
|
||||
version "1.8.24"
|
||||
resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.8.24.tgz#3cc481ad6dfe2787ec4385275dd24791f04d9e71"
|
||||
integrity sha512-PdwOBdnyBnpOag1FRX9ks4cb0GiMLKFU9HSaFTHdb/uw6fVIrnCHpELASeliOxlabWb5rOyVPbc58QpGIfZQIQ==
|
||||
|
||||
react-native-date-picker@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-4.4.2.tgz#f7bb9daa8559237e08bd30f907ee8487a6e2a6ec"
|
||||
|
|
Loading…
Reference in New Issue