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:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user