GIF Viewer (#3605)

* ios player

autoplay after recycle

remove all items from AVPlayer queue

recurururururursion

use managers in the view

add prefetch

make sure player items stay in order

add controller and item managers

start of the view

create module, ios

* android player

smoother

basic caching

prep cache

somewhat works

backup

other files

android impl

blegh

lets go

touchup

add prefetch to js

use caching

* bogus testing commit

* add dims to type

* save

* add the dimensions to the embed info

* add a new case

* add a new case

* limit this case to giphy

* use gate

* Revert "bogus testing commit"

This reverts commit b3c8751b71f7108de9aa843b22ded4e0249fa854.

* add web player base

* flip mp4/webp

* basic mp4 player for web

* move some stuff into `ExternalLinkEmbed` instead

* use a class component for web

* remove extra component

* add `onPlayerStateChange` event type on web

* layer properly

* fix tests

* add new test

* about ready. native portions done, a few touch ups on web needed

show placeholder on ios

fix type

rm log

display thumbnail until video is ready to play

add oncanplay, playsinline

remove unused method

add `isLoaded` change event

release player when finished

apply gc to the view

cleanup logs

android gc

rm log

automatic gc for assets

make `nativeRef` private

remove unnecessary `await`

cleanup

rev log

only play on prepare whenever needed

rm unused

perfperfperf

rm var

comment + android width

native height calculations

rm pressable

add event dispatcher on android

add event dispatcher on ios

* ready to test ios

fix autoplay ios

clean

oops

* autoplay on web

* normalize across all platforms

add check for `ALT:`

separate gif embed logic to another file

handle permissions requests

flatten web styles

normalize styles

normalize styles

prefetch functions

pause animatable on foreground android

nits

one more oops

idk where that code went

lint

rethink the usage

wrap up

android

clear bg

update gradle

more android

rename dir

update android namespace

web

ios

add deps

use webp

rm unused

update types

use webp on mobile

* rm gate from types

* remove unused event param

* only start placeholder op if doesn't exist in disk cache

* fix gifs animating on app resume android

* remove comment

* add `isLoaded` for ios

* add `isLoaded` to Android

* onload for web

* add visual loading state

* rm a log

* implement isloaded for android

* dialogs

* replace `webpSource` with `source`

* update prop name

* Move to Tenor for GIFs (#3654)

* update some urls

* right order for dimensions

* add GIF coder for ios

* remove giphy check

* rewrite tenor urls

* remove all the unnecessary stuff for consent

* rm print

* rm log

* check if id and filename are strings

* full size playback controls

* pass tests

* add accessibility to gifs

* use `onPlay` and `onPause`

* rm unused logic for description

* add accessibility label to the controls

* add gif into to external embed in composer

* make it optional

* gif dimensions

* make the jsx look nicer

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Hailey 2024-04-22 18:54:15 -07:00 committed by GitHub
parent fe9b3f0432
commit cbb817b5b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1223 additions and 265 deletions

View file

@ -121,6 +121,7 @@ export const ComposePost = observer(function ComposePost({
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const gallery = useMemo(
@ -318,7 +319,7 @@ export const ComposePost = observer(function ComposePost({
const onSelectGif = useCallback(
(gif: Gif) => {
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`,
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
meta: {
url: gif.media_formats.gif.url,
@ -328,6 +329,7 @@ export const ComposePost = observer(function ComposePost({
description: `ALT: ${gif.content_description}`,
},
})
setExtGif(gif)
},
[setExtLink],
)
@ -473,7 +475,11 @@ export const ComposePost = observer(function ComposePost({
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
onRemove={() => setExtLink(undefined)}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
)}
{quote ? (

View file

@ -1,11 +1,12 @@
import React from 'react'
import {TouchableOpacity, View} from 'react-native'
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 {ExternalEmbedDraft} from 'lib/api/index'
import {s} from 'lib/styles'
import {Gif} from 'state/queries/tenor'
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
export const ExternalEmbed = ({
link,
onRemove,
gif,
}: {
link?: ExternalEmbedDraft
onRemove: () => void
gif?: Gif
}) => {
const t = useTheme()
const {_} = useLingui()
@ -34,45 +37,38 @@ export const ExternalEmbed = ({
if (!link) return null
const loadingStyle: ViewStyle | undefined = gif
? {
aspectRatio:
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
width: '100%',
}
: undefined
return (
<View
style={[
a.border,
a.rounded_sm,
a.mt_2xl,
a.mb_xl,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
{link.isLoading ? (
<View
style={[
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
]}>
<Container style={loadingStyle}>
<Loader size="xl" />
</View>
</Container>
) : link.meta?.error ? (
<View
style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}>
<Container style={[a.align_start, a.p_md, a.gap_xs]}>
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
{link.uri}
</Text>
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{link.meta.error}
{link.meta?.error}
</Text>
</View>
</Container>
) : linkInfo ? (
<View style={{pointerEvents: 'none'}}>
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
<ExternalLinkEmbed link={linkInfo} />
</View>
) : null}
<TouchableOpacity
style={{
position: 'absolute',
top: 10,
top: 16,
right: 10,
height: 36,
width: 36,
@ -91,3 +87,29 @@ export const ExternalEmbed = ({
</View>
)
}
function Container({
style,
children,
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.mt_sm,
a.rounded_sm,
a.border,
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_medium,
style,
]}>
{children}
</View>
)
}

View file

@ -1,27 +1,32 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import React, {useCallback} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useGate} from 'lib/statsig/statsig'
import {shareUrl} from 'lib/sharing'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {isNative} from 'platform/detection'
import {useExternalEmbedsPrefs} from 'state/preferences'
import {Link} from 'view/com/util/Link'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../text/Text'
export const ExternalLinkEmbed = ({
link,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const externalEmbedPrefs = useExternalEmbedsPrefs()
const gate = useGate()
const embedPlayerParams = React.useMemo(() => {
const params = parseEmbedPlayerFromUrl(link.uri)
@ -30,71 +35,96 @@ export const ExternalLinkEmbed = ({
return params
}
}, [link.uri, externalEmbedPrefs])
const isCompatibleGiphy =
embedPlayerParams?.source === 'giphy' &&
embedPlayerParams.dimensions &&
gate('new_gif_player')
if (embedPlayerParams?.source === 'tenor') {
return <GifEmbed params={embedPlayerParams} link={link} />
}
return (
<View style={styles.container}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{aspectRatio: 1.91}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
{isCompatibleGiphy ? (
<View />
) : embedPlayerParams?.isGif ? (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
<View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
{!isCompatibleGiphy && (
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<LinkWrapper link={link} style={style}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{
aspectRatio: 1.91,
borderTopRightRadius: 6,
borderTopLeftRadius: 6,
}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
{embedPlayerParams?.isGif ? (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
<View
style={[
a.flex_1,
a.py_sm,
{
paddingHorizontal: isMobile ? 10 : 14,
},
]}>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, styles.extUri]}>
style={[pal.textLight, {marginVertical: 2}]}>
{toNiceDomain(link.uri)}
</Text>
)}
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description && !embedPlayerParams?.hideDetails ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, styles.extDescription]}>
{link.description}
</Text>
) : undefined}
</View>
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, a.mt_xs]}>
{link.description}
</Text>
) : undefined}
</View>
</LinkWrapper>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
borderRadius: 6,
overflow: 'hidden',
},
info: {
width: '100%',
bottom: 0,
paddingTop: 8,
paddingBottom: 10,
},
extUri: {
marginTop: 2,
},
extDescription: {
marginTop: 4,
},
})
function LinkWrapper({
link,
style,
children,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
const onShareExternal = useCallback(() => {
if (link.uri && isNative) {
shareUrl(link.uri)
}
}, [link.uri])
return (
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[
a.flex_1,
a.border,
a.rounded_sm,
t.atoms.border_contrast_medium,
style,
]}
hoverStyle={t.atoms.border_contrast_high}
onLongPress={onShareExternal}>
{children}
</Link>
)
}

View file

@ -0,0 +1,140 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {EmbedPlayerParams} from 'lib/strings/embed-player'
import {useAutoplayDisabled} from 'state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
function PlaybackControls({
onPress,
isPlaying,
isLoaded,
}: {
onPress: () => void
isPlaying: boolean
isLoaded: boolean
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={_(msg`Play or pause the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
!isLoaded && a.border,
t.atoms.border_contrast_medium,
a.inset_0,
a.w_full,
a.h_full,
{
zIndex: 2,
backgroundColor: !isLoaded
? t.atoms.bg_contrast_25.backgroundColor
: !isPlaying
? 'rgba(0, 0, 0, 0.3)'
: undefined,
},
]}
onPress={onPress}>
{!isLoaded ? (
<View>
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</View>
) : !isPlaying ? (
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.palette.primary_500,
width: 60,
height: 60,
},
]}>
<FontAwesomeIcon
icon="play"
size={42}
color="white"
style={{marginLeft: 8}}
/>
</View>
) : undefined}
</Pressable>
)
}
export function GifEmbed({
params,
link,
}: {
params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal
}) {
const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled()
const playerRef = React.useRef<GifView>(null)
const [playerState, setPlayerState] = React.useState<{
isPlaying: boolean
isLoaded: boolean
}>({
isPlaying: !autoplayDisabled,
isLoaded: false,
})
const onPlayerStateChange = React.useCallback(
(e: GifViewStateChangeEvent) => {
setPlayerState(e.nativeEvent)
},
[],
)
const onPress = React.useCallback(() => {
playerRef.current?.toggleAsync()
}, [])
return (
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<View
style={[
a.rounded_sm,
a.overflow_hidden,
{
aspectRatio: params.dimensions!.width / params.dimensions!.height,
},
]}>
<PlaybackControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
/>
<GifView
source={params.playerUri}
placeholderSource={link.thumb}
style={[a.flex_1, a.rounded_sm]}
autoplay={!autoplayDisabled}
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={link.description.replace('ALT: ', '')}
/>
</View>
</View>
)
}

View file

@ -1,34 +1,32 @@
import React, {useCallback} from 'react'
import React from 'react'
import {
StyleSheet,
InteractionManager,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
Text,
InteractionManager,
} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
ModerationDecision,
} from '@atproto/api'
import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {isNative} from '#/platform/detection'
import {shareUrl} from '#/lib/sharing'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
type Embed =
| AppBskyEmbedRecord.View
@ -49,16 +47,6 @@ export function PostEmbeds({
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
const externalUri = AppBskyEmbedExternal.isView(embed)
? embed.external.uri
: null
const onShareExternal = useCallback(() => {
if (externalUri && isNative) {
shareUrl(externalUri)
}
}, [externalUri])
// quote post with media
// =
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
@ -161,18 +149,9 @@ export function PostEmbeds({
// =
if (AppBskyEmbedExternal.isView(embed)) {
const link = embed.external
return (
<ContentHider modui={moderation?.ui('contentMedia')}>
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[styles.extOuter, pal.view, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
onLongPress={onShareExternal}>
<ExternalLinkEmbed link={link} />
</Link>
<ExternalLinkEmbed link={link} style={style} />
</ContentHider>
)
}
@ -187,11 +166,6 @@ const styles = StyleSheet.create({
singleImage: {
borderRadius: 8,
},
extOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
},
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,