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'],
|
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'expo-video',
|
||||||
|
'react-native-compressor',
|
||||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||||
'./plugins/withAndroidManifestPlugin.js',
|
'./plugins/withAndroidManifestPlugin.js',
|
||||||
'./plugins/withAndroidManifestFCMIconPlugin.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-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
|
"expo-video": "^1.1.10",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
@ -166,6 +167,7 @@
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-keyed-flatten-children": "^3.0.0",
|
"react-keyed-flatten-children": "^3.0.0",
|
||||||
"react-native": "0.74.1",
|
"react-native": "0.74.1",
|
||||||
|
"react-native-compressor": "^1.8.24",
|
||||||
"react-native-date-picker": "^4.4.2",
|
"react-native-date-picker": "^4.4.2",
|
||||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||||
"react-native-fs": "^2.20.0",
|
"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}
|
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() {
|
export function useCameraPermission() {
|
||||||
const [res, requestPermission] = Camera.useCameraPermissions()
|
const [res, requestPermission] = Camera.useCameraPermissions()
|
||||||
|
|
||||||
|
|
|
@ -14,3 +14,11 @@ export function useCameraPermission() {
|
||||||
|
|
||||||
return {requestCameraAccessIfNeeded}
|
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_feeds_interstitial'
|
||||||
| 'suggested_follows_interstitial'
|
| 'suggested_follows_interstitial'
|
||||||
| 'ungroup_follow_backs'
|
| 'ungroup_follow_backs'
|
||||||
|
| 'videos'
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, {
|
import React, {
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
@ -42,7 +43,7 @@ import {
|
||||||
} from '#/lib/gif-alt-text'
|
} from '#/lib/gif-alt-text'
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
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 {logger} from '#/logger'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
|
@ -96,6 +97,10 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
import {TextInput, TextInputRef} from './text-input/TextInput'
|
||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
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
|
import hairlineWidth = StyleSheet.hairlineWidth
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
|
@ -115,6 +120,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
}: Props & {
|
}: Props & {
|
||||||
cancelRef?: React.RefObject<CancelRef>
|
cancelRef?: React.RefObject<CancelRef>
|
||||||
}) {
|
}) {
|
||||||
|
const gate = useGate()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||||
|
@ -156,6 +162,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,
|
||||||
|
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[]>([])
|
||||||
|
@ -375,8 +389,9 @@ 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
|
const canSelectImages =
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink)
|
gallery.size < 4 && !extLink && !video && !videoPending
|
||||||
|
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
|
@ -600,7 +615,20 @@ export const ComposePost = observer(function ComposePost({
|
||||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<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]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
|
{gate('videos') && (
|
||||||
|
<SelectVideoBtn
|
||||||
|
onSelectVideo={onSelectVideo}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
<SelectGifBtn
|
<SelectGifBtn
|
||||||
onClose={focusTextInput}
|
onClose={focusTextInput}
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {ExternalEmbedDraft} from 'lib/api/index'
|
import {ExternalEmbedDraft} from 'lib/api/index'
|
||||||
import {s} from 'lib/styles'
|
|
||||||
import {Gif} from 'state/queries/tenor'
|
import {Gif} from 'state/queries/tenor'
|
||||||
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
|
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
@ -22,7 +19,6 @@ export const ExternalEmbed = ({
|
||||||
gif?: Gif
|
gif?: Gif
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
|
||||||
|
|
||||||
const linkInfo = React.useMemo(
|
const linkInfo = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -70,25 +66,7 @@ export const ExternalEmbed = ({
|
||||||
<ExternalLinkEmbed link={linkInfo} hideAlt />
|
<ExternalLinkEmbed link={linkInfo} hideAlt />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<TouchableOpacity
|
<ExternalEmbedRemoveBtn onRemove={onRemove} />
|
||||||
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>
|
|
||||||
</View>
|
</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 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
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
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}>
|
<LinkWrapper link={link} onOpen={onOpen} style={style}>
|
||||||
{imageUri && !embedPlayerParams ? (
|
{imageUri && !embedPlayerParams ? (
|
||||||
<Image
|
<Image
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -12302,6 +12302,11 @@ expo-updates@~0.25.14:
|
||||||
ignore "^5.3.1"
|
ignore "^5.3.1"
|
||||||
resolve-from "^5.0.0"
|
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:
|
expo-web-browser@~13.0.3:
|
||||||
version "13.0.3"
|
version "13.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"
|
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:
|
dependencies:
|
||||||
react-is "^18.2.0"
|
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:
|
react-native-date-picker@^4.4.2:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-4.4.2.tgz#f7bb9daa8559237e08bd30f907ee8487a6e2a6ec"
|
||||||
|
|
Loading…
Reference in New Issue