about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app.config.js2
-rw-r--r--assets/icons/videoClip_stroke2_corner0_rounded.svg1
-rw-r--r--package.json2
-rw-r--r--src/components/icons/VideoClip.tsx5
-rw-r--r--src/lib/hooks/usePermissions.ts29
-rw-r--r--src/lib/hooks/usePermissions.web.ts8
-rw-r--r--src/lib/media/video/compress.ts30
-rw-r--r--src/lib/media/video/compress.web.ts28
-rw-r--r--src/lib/media/video/errors.ts6
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/view/com/composer/Composer.tsx42
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx28
-rw-r--r--src/view/com/composer/ExternalEmbedRemoveBtn.tsx34
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx7
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx67
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx39
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx27
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.tsx37
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx7
-rw-r--r--src/view/com/composer/videos/VideoTranscodeProgress.tsx53
-rw-r--r--src/view/com/composer/videos/state.ts51
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx2
-rw-r--r--yarn.lock10
23 files changed, 483 insertions, 33 deletions
diff --git a/app.config.js b/app.config.js
index 4a4491228..1467f762f 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 000000000..fd4c08d47
--- /dev/null
+++ b/assets/icons/videoClip_stroke2_corner0_rounded.svg
@@ -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>
diff --git a/package.json b/package.json
index 5f9f03457..0ea23a274 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 000000000..c2c13c491
--- /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 9f1f8fb6f..d248e1975 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 <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()
 
diff --git a/src/lib/hooks/usePermissions.web.ts b/src/lib/hooks/usePermissions.web.ts
index c550a7d6d..b65bbc414 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 000000000..60e5e94a0
--- /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<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}
+}
diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts
new file mode 100644
index 000000000..968f2b157
--- /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<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,
+  }
+}
diff --git a/src/lib/media/video/errors.ts b/src/lib/media/video/errors.ts
new file mode 100644
index 000000000..701a7e235
--- /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 6a4081185..378b27349 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 9e2f77d4d..c8a77385e 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<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}
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index b81065e99..4801ca0ab 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -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>
   )
 }
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
new file mode 100644
index 000000000..7742900a8
--- /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 (
+    <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>
+  )
+}
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index a3fa78a59..a205fe096 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 000000000..9c528a92e
--- /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 (
+    <>
+      <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>
+    </>
+  )
+}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
new file mode 100644
index 000000000..b04cdf1c8
--- /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 (
+    <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>
+  )
+}
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 000000000..223dbd424
--- /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 (
+    <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>
+  )
+}
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
new file mode 100644
index 000000000..1f4173642
--- /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 (
+    <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>
+  )
+}
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 000000000..9b580fdf2
--- /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 (
+    <video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay />
+  )
+}
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
new file mode 100644
index 000000000..79407cd3e
--- /dev/null
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -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>
+  )
+}
diff --git a/src/view/com/composer/videos/state.ts b/src/view/com/composer/videos/state.ts
new file mode 100644
index 000000000..0d47dd056
--- /dev/null
+++ b/src/view/com/composer/videos/state.ts
@@ -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
+}
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index f5f220c62..e7fd6cb8f 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -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
diff --git a/yarn.lock b/yarn.lock
index 84318d1a4..a83d41dba 100644
--- a/yarn.lock
+++ b/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"