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') {
|
} else if (e.type === 'video') {
|
||||||
return (
|
return (
|
||||||
<Outer style={style}>
|
<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>
|
</Outer>
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
@@ -81,11 +85,29 @@ export function ImageItem({
|
|||||||
alt,
|
alt,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
thumbnail: string
|
thumbnail?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
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 (
|
return (
|
||||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||||
<Image
|
<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 (
|
return (
|
||||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
@@ -125,22 +147,6 @@ export function VideoItem({
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
alt?: 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 (
|
return (
|
||||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
@@ -157,7 +163,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 5,
|
left: 5,
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useRef, useState} from 'react'
|
import {useRef, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
Pressable,
|
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -17,60 +16,13 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
|||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Fill} from '#/components/Fill'
|
import {Fill} from '#/components/Fill'
|
||||||
import {Loader} from '#/components/Loader'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||||
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||||
|
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GifEmbed({
|
export function GifEmbed({
|
||||||
params,
|
params,
|
||||||
@@ -120,8 +72,6 @@ export function GifEmbed({
|
|||||||
style={[
|
style={[
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.border,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
{backgroundColor: t.palette.black},
|
{backgroundColor: t.palette.black},
|
||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
style,
|
style,
|
||||||
@@ -139,10 +89,11 @@ export function GifEmbed({
|
|||||||
right: -2,
|
right: -2,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<PlaybackControls
|
<MediaInsetBorder />
|
||||||
|
<GifPresentationControls
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
isPlaying={playerState.isPlaying}
|
isPlaying={playerState.isPlaying}
|
||||||
isLoaded={playerState.isLoaded}
|
isLoading={!playerState.isLoaded}
|
||||||
/>
|
/>
|
||||||
<GifView
|
<GifView
|
||||||
source={params.playerUri}
|
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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||||
|
import {GifPresentationControls} from '../GifPresentationControls'
|
||||||
import {TimeIndicator} from './TimeIndicator'
|
import {TimeIndicator} from './TimeIndicator'
|
||||||
|
|
||||||
export function VideoEmbedInnerNative({
|
export function VideoEmbedInnerNative({
|
||||||
@@ -50,12 +51,14 @@ export function VideoEmbedInnerNative({
|
|||||||
throw new Error(error)
|
throw new Error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isGif = embed.presentation === 'gif'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1, a.relative]}>
|
<View style={[a.flex_1, a.relative]}>
|
||||||
<BlueskyVideoView
|
<BlueskyVideoView
|
||||||
url={embed.playlist}
|
url={embed.playlist}
|
||||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
autoplay={!autoplayDisabled && !isWithinMessage}
|
||||||
beginMuted={autoplayDisabled ? false : muted}
|
beginMuted={isGif || autoplayDisabled ? false : muted}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onActiveChange={e => {
|
onActiveChange={e => {
|
||||||
setIsActive(e.nativeEvent.isActive)
|
setIsActive(e.nativeEvent.isActive)
|
||||||
@@ -82,25 +85,35 @@ export function VideoEmbedInnerNative({
|
|||||||
}
|
}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
<VideoControls
|
{isGif ? (
|
||||||
enterFullscreen={() => {
|
<GifPresentationControls
|
||||||
videoRef.current?.enterFullscreen(true)
|
onPress={() => {
|
||||||
}}
|
videoRef.current?.togglePlayback()
|
||||||
toggleMuted={() => {
|
}}
|
||||||
videoRef.current?.toggleMuted()
|
isPlaying={isPlaying}
|
||||||
}}
|
isLoading={false}
|
||||||
togglePlayback={() => {
|
/>
|
||||||
videoRef.current?.togglePlayback()
|
) : (
|
||||||
}}
|
<VideoPresentationControls
|
||||||
isPlaying={isPlaying}
|
enterFullscreen={() => {
|
||||||
timeRemaining={timeRemaining}
|
videoRef.current?.enterFullscreen(true)
|
||||||
/>
|
}}
|
||||||
|
toggleMuted={() => {
|
||||||
|
videoRef.current?.toggleMuted()
|
||||||
|
}}
|
||||||
|
togglePlayback={() => {
|
||||||
|
videoRef.current?.togglePlayback()
|
||||||
|
}}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
timeRemaining={timeRemaining}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MediaInsetBorder />
|
<MediaInsetBorder />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VideoControls({
|
function VideoPresentationControls({
|
||||||
enterFullscreen,
|
enterFullscreen,
|
||||||
toggleMuted,
|
toggleMuted,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
active: boolean
|
active: boolean
|
||||||
setActive: () => void
|
setActive: () => void
|
||||||
onScreen: boolean
|
onScreen: boolean
|
||||||
lastKnownTime: React.MutableRefObject<number | undefined>
|
lastKnownTime: React.RefObject<number | undefined>
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const hlsRef = useHLS({
|
const {hlsRef, loop} = useHLS({
|
||||||
playlist: embed.playlist,
|
playlist: embed.playlist,
|
||||||
setHasSubtitleTrack,
|
setHasSubtitleTrack,
|
||||||
setError,
|
setError,
|
||||||
@@ -64,11 +64,12 @@ export function VideoEmbedInnerWeb({
|
|||||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="none"
|
||||||
muted={!focused}
|
muted={embed.presentation === 'gif' || !focused}
|
||||||
aria-labelledby={embed.alt ? figId : undefined}
|
aria-labelledby={embed.alt ? figId : undefined}
|
||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => {
|
||||||
lastKnownTime.current = e.currentTarget.currentTime
|
lastKnownTime.current = e.currentTarget.currentTime
|
||||||
}}
|
}}
|
||||||
|
loop={loop}
|
||||||
/>
|
/>
|
||||||
{embed.alt && (
|
{embed.alt && (
|
||||||
<figcaption
|
<figcaption
|
||||||
@@ -99,6 +100,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
onScreen={onScreen}
|
onScreen={onScreen}
|
||||||
fullscreenRef={containerRef}
|
fullscreenRef={containerRef}
|
||||||
hasSubtitleTrack={hasSubtitleTrack}
|
hasSubtitleTrack={hasSubtitleTrack}
|
||||||
|
isGif={embed.presentation === 'gif'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</View>
|
</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(() => {
|
useEffect(() => {
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
if (!Hls) return
|
if (!Hls) return
|
||||||
@@ -242,20 +221,6 @@ function useHLS({
|
|||||||
hls.attachMedia(videoRef.current)
|
hls.attachMedia(videoRef.current)
|
||||||
hls.loadSource(playlist)
|
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, () => {
|
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||||
BandwidthEstimate.set(hls.bandwidthEstimate)
|
BandwidthEstimate.set(hls.bandwidthEstimate)
|
||||||
})
|
})
|
||||||
@@ -293,17 +258,65 @@ function useHLS({
|
|||||||
hlsRef.current = undefined
|
hlsRef.current = undefined
|
||||||
hls.detachMedia()
|
hls.detachMedia()
|
||||||
hls.destroy()
|
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()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}, [
|
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
|
||||||
playlist,
|
|
||||||
setError,
|
|
||||||
setHasSubtitleTrack,
|
|
||||||
videoRef,
|
|
||||||
handleFragChange,
|
|
||||||
flushOnLoop,
|
|
||||||
Hls,
|
|
||||||
])
|
|
||||||
|
|
||||||
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 {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
|
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||||
|
import {GifPresentationControls} from '../../GifPresentationControls'
|
||||||
import {TimeIndicator} from '../TimeIndicator'
|
import {TimeIndicator} from '../TimeIndicator'
|
||||||
import {ControlButton} from './ControlButton'
|
import {ControlButton} from './ControlButton'
|
||||||
import {Scrubber} from './Scrubber'
|
import {Scrubber} from './Scrubber'
|
||||||
@@ -44,6 +45,7 @@ export function Controls({
|
|||||||
fullscreenRef,
|
fullscreenRef,
|
||||||
hlsLoading,
|
hlsLoading,
|
||||||
hasSubtitleTrack,
|
hasSubtitleTrack,
|
||||||
|
isGif,
|
||||||
}: {
|
}: {
|
||||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||||
hlsRef: React.RefObject<Hls | undefined | null>
|
hlsRef: React.RefObject<Hls | undefined | null>
|
||||||
@@ -55,6 +57,7 @@ export function Controls({
|
|||||||
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
||||||
hlsLoading: boolean
|
hlsLoading: boolean
|
||||||
hasSubtitleTrack: boolean
|
hasSubtitleTrack: boolean
|
||||||
|
isGif: boolean
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
play,
|
play,
|
||||||
@@ -287,6 +290,16 @@ export function Controls({
|
|||||||
((focused || autoplayDisabled) && !playing) ||
|
((focused || autoplayDisabled) && !playing) ||
|
||||||
(interactingViaKeypress ? hasFocus : hovered)
|
(interactingViaKeypress ? hasFocus : hovered)
|
||||||
|
|
||||||
|
if (isGif) {
|
||||||
|
return (
|
||||||
|
<GifPresentationControls
|
||||||
|
isPlaying={playing}
|
||||||
|
isLoading={showSpinner}
|
||||||
|
onPress={onPressPlayPause}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -354,6 +354,8 @@ async function resolveMedia(
|
|||||||
alt: videoDraft.altText || undefined,
|
alt: videoDraft.altText || undefined,
|
||||||
captions: captions.length === 0 ? undefined : captions,
|
captions: captions.length === 0 ? undefined : captions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
presentation:
|
||||||
|
videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (embedDraft.media?.type === 'gif') {
|
if (embedDraft.media?.type === 'gif') {
|
||||||
|
|||||||
@@ -2290,22 +2290,40 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
|||||||
|
|
||||||
let text = ''
|
let text = ''
|
||||||
|
|
||||||
|
const isGif = state.video?.mimeType === 'image/gif'
|
||||||
|
|
||||||
switch (state.status) {
|
switch (state.status) {
|
||||||
case 'compressing':
|
case 'compressing':
|
||||||
text = _(msg`Compressing video...`)
|
if (isGif) {
|
||||||
|
text = _(msg`Compressing GIF...`)
|
||||||
|
} else {
|
||||||
|
text = _(msg`Compressing video...`)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'uploading':
|
case 'uploading':
|
||||||
text = _(msg`Uploading video...`)
|
if (isGif) {
|
||||||
|
text = _(msg`Uploading GIF...`)
|
||||||
|
} else {
|
||||||
|
text = _(msg`Uploading video...`)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'processing':
|
case 'processing':
|
||||||
text = _(msg`Processing video...`)
|
if (isGif) {
|
||||||
|
text = _(msg`Processing GIF...`)
|
||||||
|
} else {
|
||||||
|
text = _(msg`Processing video...`)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
text = _(msg`Error`)
|
text = _(msg`Error`)
|
||||||
wheelProgress = 100
|
wheelProgress = 100
|
||||||
break
|
break
|
||||||
case 'done':
|
case 'done':
|
||||||
text = _(msg`Video uploaded`)
|
if (isGif) {
|
||||||
|
text = _(msg`GIF uploaded`)
|
||||||
|
} else {
|
||||||
|
text = _(msg`Video uploaded`)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import {useRef} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
@@ -24,7 +24,7 @@ export function VideoPreview({
|
|||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const playerRef = React.useRef<BlueskyVideoView>(null)
|
const playerRef = useRef<BlueskyVideoView>(null)
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user