Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub b3cbb344 da5c356f

+239 -145
+26 -20
src/components/MediaPreview.tsx
··· 50 50 } else if (e.type === 'video') { 51 51 return ( 52 52 <Outer style={style}> 53 - <VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 53 + {e.view.presentation === 'gif' ? ( 54 + <GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 55 + ) : ( 56 + <VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 57 + )} 54 58 </Outer> 55 59 ) 56 60 } else if ( ··· 81 85 alt, 82 86 children, 83 87 }: { 84 - thumbnail: string 88 + thumbnail?: string 85 89 alt?: string 86 90 children?: React.ReactNode 87 91 }) { 88 92 const t = useTheme() 93 + 94 + if (!thumbnail) { 95 + return ( 96 + <View 97 + style={[ 98 + {backgroundColor: 'black'}, 99 + a.flex_1, 100 + a.aspect_square, 101 + {maxWidth: 100}, 102 + a.rounded_xs, 103 + ]} 104 + accessibilityLabel={alt} 105 + accessibilityHint=""> 106 + {children} 107 + </View> 108 + ) 109 + } 110 + 89 111 return ( 90 112 <View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}> 91 113 <Image ··· 103 125 ) 104 126 } 105 127 106 - export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) { 128 + export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) { 107 129 return ( 108 130 <ImageItem thumbnail={thumbnail} alt={alt}> 109 131 <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> ··· 125 147 thumbnail?: string 126 148 alt?: string 127 149 }) { 128 - if (!thumbnail) { 129 - return ( 130 - <View 131 - style={[ 132 - {backgroundColor: 'black'}, 133 - a.flex_1, 134 - a.aspect_square, 135 - {maxWidth: 100}, 136 - a.justify_center, 137 - a.align_center, 138 - a.rounded_xs, 139 - ]}> 140 - <PlayButtonIcon size={24} /> 141 - </View> 142 - ) 143 - } 144 150 return ( 145 151 <ImageItem thumbnail={thumbnail} alt={alt}> 146 152 <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> ··· 157 163 paddingHorizontal: 6, 158 164 paddingVertical: 3, 159 165 position: 'absolute', 160 - right: 5, 166 + left: 5, 161 167 bottom: 5, 162 168 zIndex: 2, 163 169 },
+5 -54
src/components/Post/Embed/ExternalEmbed/Gif.tsx
··· 1 1 import {useRef, useState} from 'react' 2 2 import { 3 - Pressable, 4 3 type StyleProp, 5 4 StyleSheet, 6 5 TouchableOpacity, ··· 17 16 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 18 17 import {atoms as a, useTheme} from '#/alf' 19 18 import {Fill} from '#/components/Fill' 20 - import {Loader} from '#/components/Loader' 19 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 21 20 import * as Prompt from '#/components/Prompt' 22 21 import {Text} from '#/components/Typography' 23 - import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 24 22 import {IS_WEB} from '#/env' 25 23 import {GifView} from '../../../../../modules/expo-bluesky-gif-view' 26 24 import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' 27 - 28 - function PlaybackControls({ 29 - onPress, 30 - isPlaying, 31 - isLoaded, 32 - }: { 33 - onPress: () => void 34 - isPlaying: boolean 35 - isLoaded: boolean 36 - }) { 37 - const {_} = useLingui() 38 - const t = useTheme() 39 - 40 - return ( 41 - <Pressable 42 - accessibilityRole="button" 43 - accessibilityHint={_(msg`Plays or pauses the GIF`)} 44 - accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)} 45 - style={[ 46 - a.absolute, 47 - a.align_center, 48 - a.justify_center, 49 - !isLoaded && a.border, 50 - t.atoms.border_contrast_medium, 51 - a.inset_0, 52 - a.w_full, 53 - a.h_full, 54 - { 55 - zIndex: 2, 56 - backgroundColor: !isLoaded 57 - ? t.atoms.bg_contrast_25.backgroundColor 58 - : undefined, 59 - }, 60 - ]} 61 - onPress={onPress}> 62 - {!isLoaded ? ( 63 - <View> 64 - <View style={[a.align_center, a.justify_center]}> 65 - <Loader size="xl" /> 66 - </View> 67 - </View> 68 - ) : !isPlaying ? ( 69 - <PlayButtonIcon /> 70 - ) : undefined} 71 - </Pressable> 72 - ) 73 - } 25 + import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls' 74 26 75 27 export function GifEmbed({ 76 28 params, ··· 120 72 style={[ 121 73 a.rounded_md, 122 74 a.overflow_hidden, 123 - a.border, 124 - t.atoms.border_contrast_low, 125 75 {backgroundColor: t.palette.black}, 126 76 {aspectRatio}, 127 77 style, ··· 139 89 right: -2, 140 90 }, 141 91 ]}> 142 - <PlaybackControls 92 + <MediaInsetBorder /> 93 + <GifPresentationControls 143 94 onPress={onPress} 144 95 isPlaying={playerState.isPlaying} 145 - isLoaded={playerState.isLoaded} 96 + isLoading={!playerState.isLoaded} 146 97 /> 147 98 <GifView 148 99 source={params.playerUri}
+78
src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx
··· 1 + import {Pressable, StyleSheet, View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {Fill} from '#/components/Fill' 7 + import {Loader} from '#/components/Loader' 8 + import {Text} from '#/components/Typography' 9 + import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 10 + 11 + export function GifPresentationControls({ 12 + onPress, 13 + isPlaying, 14 + isLoading, 15 + }: { 16 + onPress: () => void 17 + isPlaying: boolean 18 + isLoading?: boolean 19 + }) { 20 + const {_} = useLingui() 21 + const t = useTheme() 22 + 23 + return ( 24 + <> 25 + <Pressable 26 + accessibilityRole="button" 27 + accessibilityHint={_(msg`Plays or pauses the GIF`)} 28 + accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)} 29 + style={[ 30 + a.absolute, 31 + a.align_center, 32 + a.justify_center, 33 + a.inset_0, 34 + a.w_full, 35 + a.h_full, 36 + {zIndex: 2}, 37 + ]} 38 + onPress={onPress}> 39 + {isLoading ? ( 40 + <View style={[a.align_center, a.justify_center]}> 41 + <Loader size="xl" /> 42 + </View> 43 + ) : !isPlaying ? ( 44 + <PlayButtonIcon /> 45 + ) : undefined} 46 + </Pressable> 47 + {!isPlaying && ( 48 + <Fill 49 + style={[ 50 + t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg, 51 + { 52 + opacity: 0.2, 53 + zIndex: 1, 54 + }, 55 + ]} 56 + /> 57 + )} 58 + <View style={styles.gifBadgeContainer}> 59 + <Text style={[{color: 'white'}, a.font_bold, a.text_xs]}> 60 + <Trans>GIF</Trans> 61 + </Text> 62 + </View> 63 + </> 64 + ) 65 + } 66 + 67 + const styles = StyleSheet.create({ 68 + gifBadgeContainer: { 69 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 70 + borderRadius: 6, 71 + paddingHorizontal: 4, 72 + paddingVertical: 3, 73 + position: 'absolute', 74 + left: 6, 75 + bottom: 6, 76 + zIndex: 2, 77 + }, 78 + })
+28 -15
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 15 15 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 16 16 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 17 17 import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 18 + import {GifPresentationControls} from '../GifPresentationControls' 18 19 import {TimeIndicator} from './TimeIndicator' 19 20 20 21 export function VideoEmbedInnerNative({ ··· 49 50 if (error) { 50 51 throw new Error(error) 51 52 } 53 + 54 + const isGif = embed.presentation === 'gif' 52 55 53 56 return ( 54 57 <View style={[a.flex_1, a.relative]}> 55 58 <BlueskyVideoView 56 59 url={embed.playlist} 57 60 autoplay={!autoplayDisabled && !isWithinMessage} 58 - beginMuted={autoplayDisabled ? false : muted} 61 + beginMuted={isGif || autoplayDisabled ? false : muted} 59 62 style={[a.rounded_sm]} 60 63 onActiveChange={e => { 61 64 setIsActive(e.nativeEvent.isActive) ··· 82 85 } 83 86 accessibilityHint="" 84 87 /> 85 - <VideoControls 86 - enterFullscreen={() => { 87 - videoRef.current?.enterFullscreen(true) 88 - }} 89 - toggleMuted={() => { 90 - videoRef.current?.toggleMuted() 91 - }} 92 - togglePlayback={() => { 93 - videoRef.current?.togglePlayback() 94 - }} 95 - isPlaying={isPlaying} 96 - timeRemaining={timeRemaining} 97 - /> 88 + {isGif ? ( 89 + <GifPresentationControls 90 + onPress={() => { 91 + videoRef.current?.togglePlayback() 92 + }} 93 + isPlaying={isPlaying} 94 + isLoading={false} 95 + /> 96 + ) : ( 97 + <VideoPresentationControls 98 + enterFullscreen={() => { 99 + videoRef.current?.enterFullscreen(true) 100 + }} 101 + toggleMuted={() => { 102 + videoRef.current?.toggleMuted() 103 + }} 104 + togglePlayback={() => { 105 + videoRef.current?.togglePlayback() 106 + }} 107 + isPlaying={isPlaying} 108 + timeRemaining={timeRemaining} 109 + /> 110 + )} 98 111 <MediaInsetBorder /> 99 112 </View> 100 113 ) 101 114 } 102 115 103 - function VideoControls({ 116 + function VideoPresentationControls({ 104 117 enterFullscreen, 105 118 toggleMuted, 106 119 togglePlayback,
+63 -50
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 21 21 active: boolean 22 22 setActive: () => void 23 23 onScreen: boolean 24 - lastKnownTime: React.MutableRefObject<number | undefined> 24 + lastKnownTime: React.RefObject<number | undefined> 25 25 }) { 26 26 const containerRef = useRef<HTMLDivElement>(null) 27 27 const videoRef = useRef<HTMLVideoElement>(null) ··· 37 37 throw error 38 38 } 39 39 40 - const hlsRef = useHLS({ 40 + const {hlsRef, loop} = useHLS({ 41 41 playlist: embed.playlist, 42 42 setHasSubtitleTrack, 43 43 setError, ··· 64 64 style={{width: '100%', height: '100%', objectFit: 'contain'}} 65 65 playsInline 66 66 preload="none" 67 - muted={!focused} 67 + muted={embed.presentation === 'gif' || !focused} 68 68 aria-labelledby={embed.alt ? figId : undefined} 69 69 onTimeUpdate={e => { 70 70 lastKnownTime.current = e.currentTarget.currentTime 71 71 }} 72 + loop={loop} 72 73 /> 73 74 {embed.alt && ( 74 75 <figcaption ··· 99 100 onScreen={onScreen} 100 101 fullscreenRef={containerRef} 101 102 hasSubtitleTrack={hasSubtitleTrack} 103 + isGif={embed.presentation === 'gif'} 102 104 /> 103 105 </div> 104 106 </View> ··· 192 194 }, 193 195 ) 194 196 195 - const flushOnLoop = useNonReactiveCallback(() => { 196 - if (!Hls) return 197 - if (!hlsRef.current) return 198 - const hls = hlsRef.current 199 - // the above callback will catch most stale frags, but there's a corner case - 200 - // if there's only one segment in the video, it won't get flushed because it avoids 201 - // flushing the currently active segment. Therefore, we have to catch it when we loop 202 - if ( 203 - hls.nextAutoLevel > 0 && 204 - lowQualityFragments.length === 1 && 205 - lowQualityFragments[0].start === 0 206 - ) { 207 - const lowQualFrag = lowQualityFragments[0] 208 - 209 - hls.trigger(Hls.Events.BUFFER_FLUSHING, { 210 - startOffset: lowQualFrag.start, 211 - endOffset: lowQualFrag.end, 212 - type: 'video', 213 - }) 214 - setLowQualityFragments([]) 215 - } 216 - }) 217 - 218 197 useEffect(() => { 219 198 if (!videoRef.current) return 220 199 if (!Hls) return ··· 242 221 hls.attachMedia(videoRef.current) 243 222 hls.loadSource(playlist) 244 223 245 - // manually loop, so if we've flushed the first buffer it doesn't get confused 246 - const abortController = new AbortController() 247 - const {signal} = abortController 248 - const videoNode = videoRef.current 249 - videoNode.addEventListener( 250 - 'ended', 251 - () => { 252 - flushOnLoop() 253 - videoNode.currentTime = 0 254 - videoNode.play() 255 - }, 256 - {signal}, 257 - ) 258 - 259 224 hls.on(Hls.Events.FRAG_LOADED, () => { 260 225 BandwidthEstimate.set(hls.bandwidthEstimate) 261 226 }) ··· 293 258 hlsRef.current = undefined 294 259 hls.detachMedia() 295 260 hls.destroy() 261 + } 262 + }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) 263 + 264 + const flushOnLoop = useNonReactiveCallback(() => { 265 + if (!Hls) return 266 + if (!hlsRef.current) return 267 + const hls = hlsRef.current 268 + // `handleFragChange` will catch most stale frags, but there's a corner case - 269 + // if there's only one segment in the video, it won't get flushed because it avoids 270 + // flushing the currently active segment. Therefore, we have to catch it when we loop 271 + if ( 272 + hls.nextAutoLevel > 0 && 273 + lowQualityFragments.length === 1 && 274 + lowQualityFragments[0].start === 0 275 + ) { 276 + const lowQualFrag = lowQualityFragments[0] 277 + 278 + hls.trigger(Hls.Events.BUFFER_FLUSHING, { 279 + startOffset: lowQualFrag.start, 280 + endOffset: lowQualFrag.end, 281 + type: 'video', 282 + }) 283 + setLowQualityFragments([]) 284 + } 285 + }) 286 + 287 + // manually loop, so if we've flushed the first buffer it doesn't get confused 288 + const hasLowQualityFragmentAtStart = lowQualityFragments.some( 289 + frag => frag.start === 0, 290 + ) 291 + useEffect(() => { 292 + if (!videoRef.current) return 293 + 294 + // use `loop` prop on `<video>` element if the starting frag is high quality. 295 + // otherwise, we need to do it with an event listener as we may need to manually flush the frag 296 + if (!hasLowQualityFragmentAtStart) return 297 + 298 + const abortController = new AbortController() 299 + const {signal} = abortController 300 + const videoNode = videoRef.current 301 + videoNode.addEventListener( 302 + 'ended', 303 + () => { 304 + flushOnLoop() 305 + videoNode.currentTime = 0 306 + const maybePromise = videoNode.play() as Promise<void> | undefined 307 + if (maybePromise) { 308 + maybePromise.catch(() => {}) 309 + } 310 + }, 311 + {signal}, 312 + ) 313 + return () => { 296 314 abortController.abort() 297 315 } 298 - }, [ 299 - playlist, 300 - setError, 301 - setHasSubtitleTrack, 302 - videoRef, 303 - handleFragChange, 304 - flushOnLoop, 305 - Hls, 306 - ]) 316 + }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart]) 307 317 308 - return hlsRef 318 + return { 319 + hlsRef, 320 + loop: !hasLowQualityFragmentAtStart, 321 + } 309 322 }
+13
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
··· 27 27 import {Loader} from '#/components/Loader' 28 28 import {Text} from '#/components/Typography' 29 29 import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env' 30 + import {GifPresentationControls} from '../../GifPresentationControls' 30 31 import {TimeIndicator} from '../TimeIndicator' 31 32 import {ControlButton} from './ControlButton' 32 33 import {Scrubber} from './Scrubber' ··· 44 45 fullscreenRef, 45 46 hlsLoading, 46 47 hasSubtitleTrack, 48 + isGif, 47 49 }: { 48 50 videoRef: React.RefObject<HTMLVideoElement | null> 49 51 hlsRef: React.RefObject<Hls | undefined | null> ··· 55 57 fullscreenRef: React.RefObject<HTMLDivElement | null> 56 58 hlsLoading: boolean 57 59 hasSubtitleTrack: boolean 60 + isGif: boolean 58 61 }) { 59 62 const { 60 63 play, ··· 286 289 const showControls = 287 290 ((focused || autoplayDisabled) && !playing) || 288 291 (interactingViaKeypress ? hasFocus : hovered) 292 + 293 + if (isGif) { 294 + return ( 295 + <GifPresentationControls 296 + isPlaying={playing} 297 + isLoading={showSpinner} 298 + onPress={onPressPlayPause} 299 + /> 300 + ) 301 + } 289 302 290 303 return ( 291 304 <div
+2
src/lib/api/index.ts
··· 354 354 alt: videoDraft.altText || undefined, 355 355 captions: captions.length === 0 ? undefined : captions, 356 356 aspectRatio, 357 + presentation: 358 + videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default', 357 359 } 358 360 } 359 361 if (embedDraft.media?.type === 'gif') {
+22 -4
src/view/com/composer/Composer.tsx
··· 2290 2290 2291 2291 let text = '' 2292 2292 2293 + const isGif = state.video?.mimeType === 'image/gif' 2294 + 2293 2295 switch (state.status) { 2294 2296 case 'compressing': 2295 - text = _(msg`Compressing video...`) 2297 + if (isGif) { 2298 + text = _(msg`Compressing GIF...`) 2299 + } else { 2300 + text = _(msg`Compressing video...`) 2301 + } 2296 2302 break 2297 2303 case 'uploading': 2298 - text = _(msg`Uploading video...`) 2304 + if (isGif) { 2305 + text = _(msg`Uploading GIF...`) 2306 + } else { 2307 + text = _(msg`Uploading video...`) 2308 + } 2299 2309 break 2300 2310 case 'processing': 2301 - text = _(msg`Processing video...`) 2311 + if (isGif) { 2312 + text = _(msg`Processing GIF...`) 2313 + } else { 2314 + text = _(msg`Processing video...`) 2315 + } 2302 2316 break 2303 2317 case 'error': 2304 2318 text = _(msg`Error`) 2305 2319 wheelProgress = 100 2306 2320 break 2307 2321 case 'done': 2308 - text = _(msg`Video uploaded`) 2322 + if (isGif) { 2323 + text = _(msg`GIF uploaded`) 2324 + } else { 2325 + text = _(msg`Video uploaded`) 2326 + } 2309 2327 break 2310 2328 } 2311 2329
+2 -2
src/view/com/composer/videos/VideoPreview.tsx
··· 1 - import React from 'react' 1 + import {useRef} from 'react' 2 2 import {View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {type ImagePickerAsset} from 'expo-image-picker' ··· 24 24 clear: () => void 25 25 }) { 26 26 const t = useTheme() 27 - const playerRef = React.useRef<BlueskyVideoView>(null) 27 + const playerRef = useRef<BlueskyVideoView>(null) 28 28 const autoplayDisabled = useAutoplayDisabled() 29 29 let aspectRatio = asset.width / asset.height 30 30