diff --git a/app.config.js b/app.config.js index 4a449122..1467f762 100644 --- a/app.config.js +++ b/app.config.js @@ -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', diff --git a/assets/icons/videoClip_stroke2_corner0_rounded.svg b/assets/icons/videoClip_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..fd4c08d4 --- /dev/null +++ b/assets/icons/videoClip_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index 5f9f0345..0ea23a27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/icons/VideoClip.tsx b/src/components/icons/VideoClip.tsx new file mode 100644 index 00000000..c2c13c49 --- /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/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts index 9f1f8fb6..d248e197 100644 --- a/src/lib/hooks/usePermissions.ts +++ b/src/lib/hooks/usePermissions.ts @@ -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 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() diff --git a/src/lib/hooks/usePermissions.web.ts b/src/lib/hooks/usePermissions.web.ts index c550a7d6..b65bbc41 100644 --- a/src/lib/hooks/usePermissions.web.ts +++ b/src/lib/hooks/usePermissions.web.ts @@ -14,3 +14,11 @@ export function useCameraPermission() { return {requestCameraAccessIfNeeded} } + +export function useVideoLibraryPermission() { + const requestVideoAccessIfNeeded = async () => { + return true + } + + return {requestVideoAccessIfNeeded} +} diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts new file mode 100644 index 00000000..60e5e94a --- /dev/null +++ b/src/lib/media/video/compress.ts @@ -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 { + 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} +} diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts new file mode 100644 index 00000000..968f2b15 --- /dev/null +++ b/src/lib/media/video/compress.web.ts @@ -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 { + 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, + } +} diff --git a/src/lib/media/video/errors.ts b/src/lib/media/video/errors.ts new file mode 100644 index 00000000..701a7e23 --- /dev/null +++ b/src/lib/media/video/errors.ts @@ -0,0 +1,6 @@ +export class VideoTooLargeError extends Error { + constructor() { + super('Videos cannot be larger than 100MB') + this.name = 'VideoTooLargeError' + } +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 6a408118..378b2734 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -11,3 +11,4 @@ export type Gate = | 'suggested_feeds_interstitial' | 'suggested_follows_interstitial' | 'ungroup_follow_backs' + | 'videos' diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 9e2f77d4..c8a77385 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 }) { + 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( initQuote, ) + const { + video, + onSelectVideo, + videoPending, + videoProcessingData, + clearVideo, + videoProcessingProgress, + } = useVideoState({setError}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -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({ setQuote(undefined)} /> )} - ) : undefined} + ) : null} + {videoPending && videoProcessingData ? ( + + ) : ( + video && ( + // remove suspense when we get rid of lazy + + + + ) + )} @@ -619,6 +647,12 @@ export const ComposePost = observer(function ComposePost({ ]}> + {gate('videos') && ( + + )} { const t = useTheme() - const {_} = useLingui() const linkInfo = React.useMemo( () => @@ -70,25 +66,7 @@ export const ExternalEmbed = ({ ) : null} - - - + ) } diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx new file mode 100644 index 00000000..7742900a --- /dev/null +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -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 ( + + + + ) +} diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index a3fa78a5..a205fe09 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -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 diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx new file mode 100644 index 00000000..9c528a92 --- /dev/null +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -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 ( + <> + + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx new file mode 100644 index 00000000..b04cdf1c --- /dev/null +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -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 ( + + + + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx new file mode 100644 index 00000000..223dbd42 --- /dev/null +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -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 ( + + + + ) +} diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx new file mode 100644 index 00000000..1f417364 --- /dev/null +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -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 ( + + {thumbnail && ( + + )} + + ) +} diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx new file mode 100644 index 00000000..9b580fdf --- /dev/null +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export function VideoTranscodeBackdrop({uri}: {uri: string}) { + return ( +