diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index acd4b313fb..542e546c42 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -50,7 +50,11 @@ export function Embed({ } else if (e.type === 'video') { return ( - + {e.view.presentation === 'gif' ? ( + + ) : ( + + )} ) } 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 ( + + {children} + + ) + } + return ( {alt} @@ -125,22 +147,6 @@ export function VideoItem({ thumbnail?: string alt?: string }) { - if (!thumbnail) { - return ( - - - - ) - } return ( @@ -157,7 +163,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', - right: 5, + left: 5, bottom: 5, zIndex: 2, }, diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx index 0a455b546e..21211e3b33 100644 --- a/src/components/Post/Embed/ExternalEmbed/Gif.tsx +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -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 ( - - {!isLoaded ? ( - - - - - - ) : !isPlaying ? ( - - ) : undefined} - - ) -} +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, }, ]}> - + void + isPlaying: boolean + isLoading?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + + return ( + <> + + {isLoading ? ( + + + + ) : !isPlaying ? ( + + ) : undefined} + + {!isPlaying && ( + + )} + + + GIF + + + + ) +} + +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, + }, +}) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index ecc36dc33b..bb72a6c687 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -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 ( { setIsActive(e.nativeEvent.isActive) @@ -82,25 +85,35 @@ export function VideoEmbedInnerNative({ } accessibilityHint="" /> - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> + {isGif ? ( + { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + isLoading={false} + /> + ) : ( + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + )} ) } -function VideoControls({ +function VideoPresentationControls({ enterFullscreen, toggleMuted, togglePlayback, diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index add2a7afb4..1c554621ed 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({ active: boolean setActive: () => void onScreen: boolean - lastKnownTime: React.MutableRefObject + lastKnownTime: React.RefObject }) { const containerRef = useRef(null) const videoRef = useRef(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 && (
@@ -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 `