Make GIFs look like GIFs (#9809)
* set presentation field in record * refer to it as a gif in the composer * video player gif presentation style * tweak badge * only do the "manual loop" when absolutely necessary * mute gifs * reuse gif controls component for tenor gifs * update media previews in notifications * edit comment * remove outdated prop
This commit is contained in:
@@ -50,7 +50,11 @@ export function Embed({
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
{e.view.presentation === 'gif' ? (
|
||||
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
) : (
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
} else if (
|
||||
@@ -81,11 +85,29 @@ export function ImageItem({
|
||||
alt,
|
||||
children,
|
||||
}: {
|
||||
thumbnail: string
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint="">
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<Image
|
||||
@@ -103,7 +125,7 @@ export function ImageItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -125,22 +147,6 @@ export function VideoItem({
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
}) {
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -157,7 +163,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
right: 5,
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
@@ -17,60 +16,13 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||
import {type 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`Plays or pauses 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
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{!isLoaded ? (
|
||||
<View>
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
|
||||
|
||||
export function GifEmbed({
|
||||
params,
|
||||
@@ -120,8 +72,6 @@ export function GifEmbed({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: t.palette.black},
|
||||
{aspectRatio},
|
||||
style,
|
||||
@@ -139,10 +89,11 @@ export function GifEmbed({
|
||||
right: -2,
|
||||
},
|
||||
]}>
|
||||
<PlaybackControls
|
||||
<MediaInsetBorder />
|
||||
<GifPresentationControls
|
||||
onPress={onPress}
|
||||
isPlaying={playerState.isPlaying}
|
||||
isLoaded={playerState.isLoaded}
|
||||
isLoading={!playerState.isLoaded}
|
||||
/>
|
||||
<GifView
|
||||
source={params.playerUri}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function GifPresentationControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Plays or pauses the GIF`)}
|
||||
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.inset_0,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
{zIndex: 2},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
{!isPlaying && (
|
||||
<Fill
|
||||
style={[
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
opacity: 0.2,
|
||||
zIndex: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.gifBadgeContainer}>
|
||||
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gifBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {GifPresentationControls} from '../GifPresentationControls'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative({
|
||||
@@ -50,12 +51,14 @@ export function VideoEmbedInnerNative({
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
const isGif = embed.presentation === 'gif'
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<BlueskyVideoView
|
||||
url={embed.playlist}
|
||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
||||
beginMuted={autoplayDisabled ? false : muted}
|
||||
beginMuted={isGif || autoplayDisabled ? false : muted}
|
||||
style={[a.rounded_sm]}
|
||||
onActiveChange={e => {
|
||||
setIsActive(e.nativeEvent.isActive)
|
||||
@@ -82,25 +85,35 @@ export function VideoEmbedInnerNative({
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<VideoControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
{isGif ? (
|
||||
<GifPresentationControls
|
||||
onPress={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
isLoading={false}
|
||||
/>
|
||||
) : (
|
||||
<VideoPresentationControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoControls({
|
||||
function VideoPresentationControls({
|
||||
enterFullscreen,
|
||||
toggleMuted,
|
||||
togglePlayback,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
onScreen: boolean
|
||||
lastKnownTime: React.MutableRefObject<number | undefined>
|
||||
lastKnownTime: React.RefObject<number | undefined>
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
|
||||
throw error
|
||||
}
|
||||
|
||||
const hlsRef = useHLS({
|
||||
const {hlsRef, loop} = useHLS({
|
||||
playlist: embed.playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
@@ -64,11 +64,12 @@ export function VideoEmbedInnerWeb({
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
muted={!focused}
|
||||
muted={embed.presentation === 'gif' || !focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
onTimeUpdate={e => {
|
||||
lastKnownTime.current = e.currentTarget.currentTime
|
||||
}}
|
||||
loop={loop}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<figcaption
|
||||
@@ -99,6 +100,7 @@ export function VideoEmbedInnerWeb({
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
isGif={embed.presentation === 'gif'}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
@@ -192,29 +194,6 @@ function useHLS({
|
||||
},
|
||||
)
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// the above callback will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
if (!Hls) return
|
||||
@@ -242,20 +221,6 @@ function useHLS({
|
||||
hls.attachMedia(videoRef.current)
|
||||
hls.loadSource(playlist)
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
videoNode.play()
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||
BandwidthEstimate.set(hls.bandwidthEstimate)
|
||||
})
|
||||
@@ -293,17 +258,65 @@ function useHLS({
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// `handleFragChange` will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const hasLowQualityFragmentAtStart = lowQualityFragments.some(
|
||||
frag => frag.start === 0,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
// use `loop` prop on `<video>` element if the starting frag is high quality.
|
||||
// otherwise, we need to do it with an event listener as we may need to manually flush the frag
|
||||
if (!hasLowQualityFragmentAtStart) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
const maybePromise = videoNode.play() as Promise<void> | undefined
|
||||
if (maybePromise) {
|
||||
maybePromise.catch(() => {})
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [
|
||||
playlist,
|
||||
setError,
|
||||
setHasSubtitleTrack,
|
||||
videoRef,
|
||||
handleFragChange,
|
||||
flushOnLoop,
|
||||
Hls,
|
||||
])
|
||||
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
|
||||
|
||||
return hlsRef
|
||||
return {
|
||||
hlsRef,
|
||||
loop: !hasLowQualityFragmentAtStart,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
import {GifPresentationControls} from '../../GifPresentationControls'
|
||||
import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
@@ -44,6 +45,7 @@ export function Controls({
|
||||
fullscreenRef,
|
||||
hlsLoading,
|
||||
hasSubtitleTrack,
|
||||
isGif,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
hlsRef: React.RefObject<Hls | undefined | null>
|
||||
@@ -55,6 +57,7 @@ export function Controls({
|
||||
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
||||
hlsLoading: boolean
|
||||
hasSubtitleTrack: boolean
|
||||
isGif: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
@@ -287,6 +290,16 @@ export function Controls({
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
if (isGif) {
|
||||
return (
|
||||
<GifPresentationControls
|
||||
isPlaying={playing}
|
||||
isLoading={showSpinner}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -354,6 +354,8 @@ async function resolveMedia(
|
||||
alt: videoDraft.altText || undefined,
|
||||
captions: captions.length === 0 ? undefined : captions,
|
||||
aspectRatio,
|
||||
presentation:
|
||||
videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default',
|
||||
}
|
||||
}
|
||||
if (embedDraft.media?.type === 'gif') {
|
||||
|
||||
@@ -2290,22 +2290,40 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
|
||||
let text = ''
|
||||
|
||||
const isGif = state.video?.mimeType === 'image/gif'
|
||||
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
text = _(msg`Compressing video...`)
|
||||
if (isGif) {
|
||||
text = _(msg`Compressing GIF...`)
|
||||
} else {
|
||||
text = _(msg`Compressing video...`)
|
||||
}
|
||||
break
|
||||
case 'uploading':
|
||||
text = _(msg`Uploading video...`)
|
||||
if (isGif) {
|
||||
text = _(msg`Uploading GIF...`)
|
||||
} else {
|
||||
text = _(msg`Uploading video...`)
|
||||
}
|
||||
break
|
||||
case 'processing':
|
||||
text = _(msg`Processing video...`)
|
||||
if (isGif) {
|
||||
text = _(msg`Processing GIF...`)
|
||||
} else {
|
||||
text = _(msg`Processing video...`)
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
text = _(msg`Error`)
|
||||
wheelProgress = 100
|
||||
break
|
||||
case 'done':
|
||||
text = _(msg`Video uploaded`)
|
||||
if (isGif) {
|
||||
text = _(msg`GIF uploaded`)
|
||||
} else {
|
||||
text = _(msg`Video uploaded`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
@@ -24,7 +24,7 @@ export function VideoPreview({
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playerRef = React.useRef<BlueskyVideoView>(null)
|
||||
const playerRef = useRef<BlueskyVideoView>(null)
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
|
||||
Reference in New Issue
Block a user