Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add gif support to web #6433

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const SUPPORTED_MIME_TYPES = [
'video/mpeg',
'video/webm',
'video/quicktime',
'image/gif',
] as const

export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
4 changes: 4 additions & 0 deletions src/lib/media/video/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
return 'mpeg'
case 'video/quicktime':
return 'mov'
case 'image/gif':
return 'gif'
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
Expand All @@ -47,6 +49,8 @@ export function extToMime(ext: string) {
return 'video/mpeg'
case 'mov':
return 'video/quicktime'
case 'gif':
return 'image/gif'
default:
throw new Error(`Unsupported file extension: ${ext}`)
}
Expand Down
35 changes: 20 additions & 15 deletions src/view/com/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {until} from '#/lib/async/until'
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
SupportedMimeTypes,
} from '#/lib/constants'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useEmail} from '#/lib/hooks/useEmail'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {mimeToExt} from '#/lib/media/video/util'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {colors, s} from '#/lib/styles'
Expand Down Expand Up @@ -130,6 +135,7 @@ import {
ThreadDraft,
} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
import {getVideoMetadata} from './videos/pickVideo'

type CancelRef = {
onPressCancel: () => void
Expand Down Expand Up @@ -744,14 +750,24 @@ let ComposerPost = React.memo(function ComposerPost({

const onPhotoPasted = useCallback(
async (uri: string) => {
if (uri.startsWith('data:video/')) {
onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
if (isNative) return // web only
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to paste videos on native? do we need to handle this? it will be broken in prod anyway since the old code sets the height/width to 0

const [mimeType] = uri.slice('data:'.length).split(';')
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
Toast.show(_(msg`Unsupported video type`), 'xmark')
return
}
const name = `pasted.${mimeToExt(mimeType)}`
const file = await fetch(uri)
.then(res => res.blob())
.then(blob => new File([blob], name, {type: mimeType}))
onSelectVideo(post.id, await getVideoMetadata(file))
} else {
const res = await pasteImage(uri)
onImageAdd([res])
}
},
[post.id, onSelectVideo, onImageAdd],
[post.id, onSelectVideo, onImageAdd, _],
)

return (
Expand Down Expand Up @@ -1007,17 +1023,6 @@ function ComposerEmbeds({
asset={video.asset}
video={video.video}
isActivePost={isActivePost}
setDimensions={(width: number, height: number) => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_dimensions',
width,
height,
signal: video.abortController.signal,
},
})
}}
clear={clearVideo}
/>
) : null)}
Expand Down
13 changes: 0 additions & 13 deletions src/view/com/composer/state/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,6 @@ export type VideoAction =
signal: AbortSignal
}
| {type: 'update_progress'; progress: number; signal: AbortSignal}
| {
type: 'update_dimensions'
width: number
height: number
signal: AbortSignal
}
| {
type: 'update_alt_text'
altText: string
Expand Down Expand Up @@ -185,13 +179,6 @@ export function videoReducer(
progress: action.progress,
}
}
} else if (action.type === 'update_dimensions') {
if (state.asset) {
return {
...state,
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'update_alt_text') {
return {
...state,
Expand Down
42 changes: 11 additions & 31 deletions src/view/com/composer/videos/SelectVideoBtn.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import React, {useCallback} from 'react'
import {Keyboard} from 'react-native'
import {
ImagePickerAsset,
launchImageLibraryAsync,
MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'

Expand All @@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
import * as Prompt from '#/components/Prompt'
import {pickVideo} from './pickVideo'

const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds

Expand Down Expand Up @@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
Keyboard.dismiss()
control.open()
} else {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
const response = await pickVideo()
if (response.assets && response.assets.length > 0) {
const asset = response.assets[0]
try {
if (isWeb) {
// asset.duration is null for gifs (see the TODO in pickVideo.web.ts)
if (asset.duration && asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
// compression step on native converts to mp4, so no need to check there
const mimeType = getMimeType(asset)
if (
!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
!SUPPORTED_MIME_TYPES.includes(
asset.mimeType as SupportedMimeTypes,
)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
Expand Down Expand Up @@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
</>
)
}

function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}
1 change: 0 additions & 1 deletion src/view/com/composer/videos/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function VideoPreview({
asset: ImagePickerAsset
video: CompressedVideo
isActivePost: boolean
setDimensions: (width: number, height: number) => void
clear: () => void
}) {
const t = useTheme()
Expand Down
87 changes: 33 additions & 54 deletions src/view/com/composer/videos/VideoPreview.web.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useEffect, useRef} from 'react'
import React from 'react'
import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
Expand All @@ -12,58 +12,22 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'

const MAX_DURATION = 60

export function VideoPreview({
asset,
video,
setDimensions,

clear,
}: {
asset: ImagePickerAsset
video: CompressedVideo
setDimensions: (width: number, height: number) => void

clear: () => void
}) {
const ref = useRef<HTMLVideoElement>(null)
const {_} = useLingui()
// TODO: figure out how to pause a GIF for reduced motion
// it's not possible using an img tag -sfn
const autoplayDisabled = useAutoplayDisabled()

useEffect(() => {
if (!ref.current) return

const abortController = new AbortController()
const {signal} = abortController
ref.current.addEventListener(
'loadedmetadata',
function () {
setDimensions(this.videoWidth, this.videoHeight)
if (!isNaN(this.duration)) {
if (this.duration > MAX_DURATION) {
Toast.show(
_(msg`Videos must be less than 60 seconds long`),
'xmark',
)
clear()
}
}
},
{signal},
)
ref.current.addEventListener(
'error',
() => {
Toast.show(_(msg`Could not process your video`), 'xmark')
clear()
},
{signal},
)

return () => {
abortController.abort()
}
}, [setDimensions, _, clear])

let aspectRatio = asset.width / asset.height

if (isNaN(aspectRatio)) {
Expand All @@ -83,19 +47,34 @@ export function VideoPreview({
a.relative,
]}>
<ExternalEmbedRemoveBtn onRemove={clear} />
<video
ref={ref}
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay={!autoplayDisabled}
loop
muted
playsInline
/>
{autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
{video.mimeType === 'image/gif' ? (
<img
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
alt="GIF"
/>
) : (
<>
<video
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
autoPlay={!autoplayDisabled}
loop
muted
playsInline
onError={err => {
console.error('Error loading video', err)
Toast.show(_(msg`Could not process your video`), 'xmark')
clear()
}}
/>
{autoplayDisabled && (
<View
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
)}
</>
)}
</View>
)
Expand Down
21 changes: 21 additions & 0 deletions src/view/com/composer/videos/pickVideo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
ImagePickerAsset,
launchImageLibraryAsync,
MediaTypeOptions,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'

export async function pickVideo() {
return await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
}

export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
throw new Error('getVideoMetadata is web only')
}
Loading
Loading