Merge branch 'main' into app-1788

This commit is contained in:
vineyardbovines
2026-02-05 08:59:22 -05:00
64 changed files with 29009 additions and 22510 deletions
+4 -4
View File
@@ -313,22 +313,22 @@ module.exports = function (_config) {
{ {
ios: { ios: {
enableFullScreenImage_legacy: true, // iOS only enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#A8CCFF', // primary_200 backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png', image: './assets/splash/splash.png',
resizeMode: 'cover', resizeMode: 'cover',
dark: { dark: {
enableFullScreenImage_legacy: true, // iOS only enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#00398A', // primary_800 backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png', image: './assets/splash/splash-dark.png',
resizeMode: 'cover', resizeMode: 'cover',
}, },
}, },
android: { android: {
backgroundColor: '#A8CCFF', // primary_200 backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png', image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px imageWidth: 102, // even division of 306px
dark: { dark: {
backgroundColor: '#00398A', // primary_800 backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png', image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, imageWidth: 102,
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+25 -19
View File
@@ -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}>
{e.view.presentation === 'gif' ? (
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
) : (
<VideoItem 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,
}, },
+8 -128
View File
@@ -1,76 +1,17 @@
import {useRef, useState} from 'react' import {useRef, useState} from 'react'
import { import {type StyleProp, View, type ViewStyle} from 'react-native'
Pressable, import {msg} from '@lingui/macro'
type StyleProp,
StyleSheet,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {type EmbedPlayerParams} from '#/lib/strings/embed-player' import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
import {useAutoplayDisabled} from '#/state/preferences' import {useAutoplayDisabled} from '#/state/preferences'
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 {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
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 +61,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 +78,12 @@ 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}
altText={!hideAlt && isPreferredAltText ? altText : undefined}
/> />
<GifView <GifView
source={params.playerUri} source={params.playerUri}
@@ -164,68 +105,7 @@ export function GifEmbed({
]} ]}
/> />
)} )}
{!hideAlt && isPreferredAltText && <AltText text={altText} />}
</View> </View>
</View> </View>
) )
} }
function AltText({text}: {text: string}) {
const control = Prompt.usePromptControl()
const largeAltBadge = useLargeAltBadgeEnabled()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altContainer}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: IS_WEB ? 8 : 6,
paddingVertical: IS_WEB ? 6 : 3,
position: 'absolute',
// Related to margin/gap hack. This keeps the alt label in the same position
// on all platforms
right: IS_WEB ? 8 : 5,
bottom: IS_WEB ? 8 : 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: IS_WEB ? 10 : 7,
fontWeight: '600',
},
})
@@ -0,0 +1,132 @@
import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Fill} from '#/components/Fill'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function GifPresentationControls({
onPress,
isPlaying,
isLoading,
altText,
}: {
onPress: () => void
isPlaying: boolean
isLoading?: boolean
altText?: string
}) {
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>
{altText && <AltBadge text={altText} />}
</>
)
}
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
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,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 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,7 +85,17 @@ export function VideoEmbedInnerNative({
} }
accessibilityHint="" accessibilityHint=""
/> />
<VideoControls {isGif ? (
<GifPresentationControls
onPress={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
isLoading={false}
altText={embed.alt}
/>
) : (
<VideoPresentationControls
enterFullscreen={() => { enterFullscreen={() => {
videoRef.current?.enterFullscreen(true) videoRef.current?.enterFullscreen(true)
}} }}
@@ -95,12 +108,13 @@ export function VideoEmbedInnerNative({
isPlaying={isPlaying} isPlaying={isPlaying}
timeRemaining={timeRemaining} 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,8 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen} onScreen={onScreen}
fullscreenRef={containerRef} fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack} hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
altText={embed.alt}
/> />
</div> </div>
</View> </View>
@@ -192,29 +195,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 +222,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 +259,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,8 @@ export function Controls({
fullscreenRef, fullscreenRef,
hlsLoading, hlsLoading,
hasSubtitleTrack, hasSubtitleTrack,
isGif,
altText,
}: { }: {
videoRef: React.RefObject<HTMLVideoElement | null> videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null> hlsRef: React.RefObject<Hls | undefined | null>
@@ -55,6 +58,8 @@ export function Controls({
fullscreenRef: React.RefObject<HTMLDivElement | null> fullscreenRef: React.RefObject<HTMLDivElement | null>
hlsLoading: boolean hlsLoading: boolean
hasSubtitleTrack: boolean hasSubtitleTrack: boolean
isGif: boolean
altText?: string
}) { }) {
const { const {
play, play,
@@ -125,13 +130,14 @@ export function Controls({
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
useEffect(() => { useEffect(() => {
if (active) { if (active) {
if (onScreen) { // GIFs play immediately, videos wait until onScreen
if (onScreen || isGif) {
if (!autoplayDisabled) play() if (!autoplayDisabled) play()
} else { } else {
pause() pause()
} }
} }
}, [onScreen, pause, active, play, autoplayDisabled]) }, [onScreen, pause, active, play, autoplayDisabled, isGif])
// use minimal quality when not focused // use minimal quality when not focused
useEffect(() => { useEffect(() => {
@@ -287,6 +293,17 @@ 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}
altText={altText}
/>
)
}
return ( return (
<div <div
style={{ style={{
@@ -26,15 +26,25 @@ import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext' import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {}
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme() const t = useTheme()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} = const {
useActiveVideoWeb() active: activeFromContext,
setActive,
sendPosition,
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false) const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen() const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined) const lastKnownTime = useRef<number | undefined>(undefined)
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => { useEffect(() => {
if (!ref.current) return if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -43,15 +53,18 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const entry = entries[0] const entry = entries[0]
if (!entry) return if (!entry) return
setOnScreen(entry.isIntersecting) setOnScreen(entry.isIntersecting)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition( sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2, entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
) )
}
}, },
{threshold: 0.5}, {threshold: 0.5},
) )
observer.observe(ref.current) observer.observe(ref.current)
return () => observer.disconnect() return () => observer.disconnect()
}, [sendPosition, isFullscreen]) }, [sendPosition, isFullscreen, isGif])
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const renderError = useCallback( const renderError = useCallback(
@@ -107,7 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
return ( return (
<View style={[a.pt_xs]}> <View style={[a.pt_xs]}>
<ViewportObserver <ViewportObserver
sendPosition={sendPosition} sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}> isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage <ConstrainedImage
fullBleed fullBleed
@@ -0,0 +1,154 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {createIsEnabledCheck, isExistingUserAsOf} from './utils'
export const enabled = createIsEnabledCheck(props => {
return (
!IS_E2E &&
IS_NATIVE &&
isExistingUserAsOf(
'2026-02-05T00:00:00.000Z',
props.currentProfile.createdAt,
)
)
})
export function DraftsAnnouncement() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle fill={t.palette.primary_400} />
<Dialog.ScrollableInner
label={_(msg`Introducing drafts`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
{
paddingTop: IS_WEB ? 24 : 40,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<LinearGradient
colors={[t.palette.primary_100, t.palette.primary_200]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 0, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View
style={[a.flex_row, a.align_center, a.gap_xs, {marginBottom: -12}]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[
a.font_semi_bold,
{
color: t.palette.primary_800,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/drafts_announcement_nux.webp')}
style={[
a.w_full,
{
aspectRatio: 393 / 226,
},
]}
alt={_(
msg({
message: `A screenshot of a the post composer with a new button next to the post button that says "Drafts", with a rainbow firework effect. Below, the text in the composer reads "Hey, did you hear the news? Bluesky has drafts now???".`,
comment:
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
/>
</View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
<View style={[a.gap_sm, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_bold,
a.text_center,
{
fontSize: IS_WEB ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Drafts</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
Not ready to hit post? Keep your best ideas in Drafts until the
timing is just right.
</Trans>
</Text>
</View>
{!IS_WEB && (
<Button
label={_(msg`Close`)}
size="large"
color="primary"
onPress={() => control.close()}
style={[a.w_full]}>
<ButtonText>
<Trans>Finally!</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+6 -6
View File
@@ -19,9 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session' import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell' import {useOnboardingState} from '#/state/shell'
import { import {
enabled as isLiveNowBetaDialogEnabled, DraftsAnnouncement,
LiveNowBetaDialog, enabled as isDraftsAnnouncementEnabled,
} from '#/components/dialogs/nuxs/LiveNowBetaDialog' } from '#/components/dialogs/nuxs/DraftsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -37,8 +37,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean enabled?: (props: EnabledCheckProps) => boolean
}[] = [ }[] = [
{ {
id: Nux.LiveNowBetaDialog, id: Nux.DraftsAnnouncement,
enabled: isLiveNowBetaDialogEnabled, enabled: isDraftsAnnouncementEnabled,
}, },
] ]
@@ -186,7 +186,7 @@ function Inner({
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />} {activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
</Context.Provider> </Context.Provider>
) )
} }
+2
View File
@@ -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') {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+95 -54
View File
@@ -579,6 +579,11 @@ msgstr ""
msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature." msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature."
msgstr "" msgstr ""
#. Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:97
msgid "A screenshot of a the post composer with a new button next to the post button that says \"Drafts\", with a rainbow firework effect. Below, the text in the composer reads \"Hey, did you hear the news? Bluesky has drafts now???\"."
msgstr ""
#: src/Navigation.tsx:535 #: src/Navigation.tsx:535
#: src/screens/Settings/AboutSettings.tsx:73 #: src/screens/Settings/AboutSettings.tsx:73
#: src/screens/Settings/Settings.tsx:260 #: src/screens/Settings/Settings.tsx:260
@@ -780,7 +785,7 @@ msgid "Add image"
msgstr "" msgstr ""
#. Accessibility label for button in composer to add images, a video, or a GIF to a post #. Accessibility label for button in composer to add images, a video, or a GIF to a post
#: src/view/com/composer/SelectMediaButton.tsx:481 #: src/view/com/composer/SelectMediaButton.tsx:499
msgid "Add media to post" msgid "Add media to post"
msgstr "" msgstr ""
@@ -1016,7 +1021,7 @@ msgstr ""
msgid "Already signed in as @{0}" msgid "Already signed in as @{0}"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:191 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:89
#: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/GifAltText.tsx:100
#: src/view/com/composer/photos/Gallery.tsx:188 #: src/view/com/composer/photos/Gallery.tsx:188
msgid "ALT" msgid "ALT"
@@ -1032,7 +1037,7 @@ msgstr ""
msgid "Alt text" msgid "Alt text"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:197 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:95
msgid "Alt Text" msgid "Alt Text"
msgstr "" msgstr ""
@@ -1058,7 +1063,7 @@ msgstr ""
msgid "An error has occurred" msgid "An error has occurred"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:421 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:438
msgid "An error occurred" msgid "An error occurred"
msgstr "" msgstr ""
@@ -1087,7 +1092,7 @@ msgstr ""
msgid "An error occurred while loading the video. Please try again later." msgid "An error occurred while loading the video. Please try again later."
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:226 #: src/components/Post/Embed/VideoEmbed/index.web.tsx:239
msgid "An error occurred while loading the video. Please try again." msgid "An error occurred while loading the video. Please try again."
msgstr "" msgstr ""
@@ -1180,7 +1185,7 @@ msgstr ""
msgid "Animals" msgid "Animals"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:154 #: src/components/Post/Embed/ExternalEmbed/Gif.tsx:95
msgid "Animated GIF" msgid "Animated GIF"
msgstr "" msgstr ""
@@ -1404,8 +1409,8 @@ msgstr ""
#: src/screens/Settings/components/ChangePasswordDialog.tsx:281 #: src/screens/Settings/components/ChangePasswordDialog.tsx:281
#: src/screens/Signup/BackNextButtons.tsx:41 #: src/screens/Signup/BackNextButtons.tsx:41
#: src/screens/StarterPack/Wizard/index.tsx:324 #: src/screens/StarterPack/Wizard/index.tsx:324
#: src/view/com/composer/drafts/DraftsListDialog.tsx:79 #: src/view/com/composer/drafts/DraftsListDialog.tsx:80
#: src/view/com/composer/drafts/DraftsListDialog.tsx:85 #: src/view/com/composer/drafts/DraftsListDialog.tsx:86
msgid "Back" msgid "Back"
msgstr "" msgstr ""
@@ -2093,6 +2098,7 @@ msgstr ""
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:163 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:163
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:171 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:171
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:138
#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:175 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:175
#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:184 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:184
#: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:190 #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:190
@@ -2108,7 +2114,7 @@ msgstr ""
#: src/components/live/EditLiveDialog.tsx:221 #: src/components/live/EditLiveDialog.tsx:221
#: src/components/NewskieDialog.tsx:167 #: src/components/NewskieDialog.tsx:167
#: src/components/NewskieDialog.tsx:173 #: src/components/NewskieDialog.tsx:173
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:204 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:102
#: src/components/ProgressGuide/FollowDialog.tsx:447 #: src/components/ProgressGuide/FollowDialog.tsx:447
#: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:118 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:118
#: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:124 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:124
@@ -2253,7 +2259,11 @@ msgstr ""
msgid "Compose reply" msgid "Compose reply"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2295 #: src/view/com/composer/Composer.tsx:2298
msgid "Compressing GIF..."
msgstr ""
#: src/view/com/composer/Composer.tsx:2300
msgid "Compressing video..." msgid "Compressing video..."
msgstr "" msgstr ""
@@ -2978,7 +2988,7 @@ msgstr ""
msgid "Disable replies entirely" msgid "Disable replies entirely"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:387 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:404
msgid "Disable subtitles" msgid "Disable subtitles"
msgstr "" msgstr ""
@@ -3170,9 +3180,10 @@ msgstr ""
msgid "Doxxing" msgid "Doxxing"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:118
#: src/view/com/composer/drafts/DraftsButton.tsx:65 #: src/view/com/composer/drafts/DraftsButton.tsx:65
#: src/view/com/composer/drafts/DraftsButton.tsx:74 #: src/view/com/composer/drafts/DraftsButton.tsx:74
#: src/view/com/composer/drafts/DraftsListDialog.tsx:111 #: src/view/com/composer/drafts/DraftsListDialog.tsx:112
msgid "Drafts" msgid "Drafts"
msgstr "" msgstr ""
@@ -3425,7 +3436,7 @@ msgstr ""
msgid "Enable quote posts of this post" msgid "Enable quote posts of this post"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:388 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405
msgid "Enable subtitles" msgid "Enable subtitles"
msgstr "" msgstr ""
@@ -3476,7 +3487,7 @@ msgstr ""
msgid "Enter code" msgid "Enter code"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:423
msgid "Enter fullscreen" msgid "Enter fullscreen"
msgstr "" msgstr ""
@@ -3513,7 +3524,7 @@ msgstr ""
msgid "Enter your username and password" msgid "Enter your username and password"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:132 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:146
msgid "Enters full screen" msgid "Enters full screen"
msgstr "" msgstr ""
@@ -3521,7 +3532,7 @@ msgstr ""
msgid "Entertainment" msgid "Entertainment"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2304 #: src/view/com/composer/Composer.tsx:2318
#: src/view/com/util/error/ErrorScreen.tsx:42 #: src/view/com/util/error/ErrorScreen.tsx:42
msgid "Error" msgid "Error"
msgstr "" msgstr ""
@@ -3582,7 +3593,7 @@ msgstr ""
msgid "Excludes users you follow" msgid "Excludes users you follow"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:422
msgid "Exit fullscreen" msgid "Exit fullscreen"
msgstr "" msgstr ""
@@ -3610,7 +3621,7 @@ msgstr ""
msgid "Expands or collapses post text" msgid "Expands or collapses post text"
msgstr "" msgstr ""
#: src/lib/api/index.ts:418 #: src/lib/api/index.ts:420
msgid "Expected uri to resolve to a record" msgid "Expected uri to resolve to a record"
msgstr "" msgstr ""
@@ -4021,6 +4032,10 @@ msgstr ""
msgid "Finalizing" msgid "Finalizing"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:144
msgid "Finally!"
msgstr ""
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:154 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:154
msgid "Finally! Keep track of posts that matter to you. Save them to revisit anytime." msgid "Finally! Keep track of posts that matter to you. Save them to revisit anytime."
msgstr "" msgstr ""
@@ -4389,10 +4404,15 @@ msgstr ""
msgid "Get started" msgid "Get started"
msgstr "" msgstr ""
#: src/components/MediaPreview.tsx:114 #: src/components/MediaPreview.tsx:136
#: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:64
msgid "GIF" msgid "GIF"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2323
msgid "GIF uploaded"
msgstr ""
#: src/screens/Onboarding/StepProfile/index.tsx:242 #: src/screens/Onboarding/StepProfile/index.tsx:242
msgid "Give your profile a face" msgid "Give your profile a face"
msgstr "" msgstr ""
@@ -5009,6 +5029,10 @@ msgstr ""
msgid "Introducing activity notifications" msgid "Introducing activity notifications"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:48
msgid "Introducing drafts"
msgstr ""
#: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:55 #: src/components/dialogs/nuxs/FindContactsAnnouncement.tsx:55
msgid "Introducing finding friends via contacts" msgid "Introducing finding friends via contacts"
msgstr "" msgstr ""
@@ -5846,7 +5870,7 @@ msgstr ""
msgid "Music" msgid "Music"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:153 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:167
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:97 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:97
msgctxt "video" msgctxt "video"
msgid "Mute" msgid "Mute"
@@ -6020,6 +6044,7 @@ msgstr ""
#: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:74 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:74
#: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:73 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:73
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:84
#: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:64 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:64
msgid "New Feature" msgid "New Feature"
msgstr "" msgstr ""
@@ -6151,7 +6176,7 @@ msgstr ""
msgid "No DNS Panel" msgid "No DNS Panel"
msgstr "" msgstr ""
#: src/view/com/composer/drafts/DraftsListDialog.tsx:135 #: src/view/com/composer/drafts/DraftsListDialog.tsx:136
msgid "No drafts yet" msgid "No drafts yet"
msgstr "" msgstr ""
@@ -6339,6 +6364,10 @@ msgstr ""
msgid "Not Found" msgid "Not Found"
msgstr "" msgstr ""
#: src/components/dialogs/nuxs/DraftsAnnouncement.tsx:129
msgid "Not ready to hit post? Keep your best ideas in Drafts until the timing is just right."
msgstr ""
#: src/view/com/profile/ProfileMenu.tsx:558 #: src/view/com/profile/ProfileMenu.tsx:558
msgid "Note about sharing" msgid "Note about sharing"
msgstr "" msgstr ""
@@ -6466,11 +6495,11 @@ msgstr ""
msgid "One or more images is missing alt text." msgid "One or more images is missing alt text."
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:393 #: src/view/com/composer/SelectMediaButton.tsx:411
msgid "One or more of your selected files are not supported." msgid "One or more of your selected files are not supported."
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:416 #: src/view/com/composer/SelectMediaButton.tsx:434
msgid "One or more of your selected files are too large. Maximum size is 100 MB." msgid "One or more of your selected files are too large. Maximum size is 100 MB."
msgstr "" msgstr ""
@@ -6639,7 +6668,7 @@ msgid "Opens device camera"
msgstr "" msgstr ""
#. Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change. #. Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.
#: src/view/com/composer/SelectMediaButton.tsx:487 #: src/view/com/composer/SelectMediaButton.tsx:505
msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF." msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF."
msgstr "" msgstr ""
@@ -6812,13 +6841,13 @@ msgstr ""
msgid "Password updated!" msgid "Password updated!"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:44 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:32
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:137 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:151
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:368 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:385
msgid "Pause" msgid "Pause"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:319 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:336
msgid "Pause video" msgid "Pause video"
msgstr "" msgstr ""
@@ -6923,9 +6952,9 @@ msgstr ""
msgid "Pinned to your feeds" msgid "Pinned to your feeds"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:44 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:32
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:137 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:151
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:369 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:386
msgid "Play" msgid "Play"
msgstr "" msgstr ""
@@ -6934,7 +6963,7 @@ msgid "Play {0}"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.tsx:115 #: src/components/Post/Embed/VideoEmbed/index.tsx:115
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:320 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:337
msgid "Play video" msgid "Play video"
msgstr "" msgstr ""
@@ -6942,11 +6971,11 @@ msgstr ""
msgid "Play Video" msgid "Play Video"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:43 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:31
msgid "Plays or pauses the GIF" msgid "Plays or pauses the GIF"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:138 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:152
msgid "Plays or pauses the video" msgid "Plays or pauses the video"
msgstr "" msgstr ""
@@ -7315,7 +7344,11 @@ msgstr ""
msgid "Privacy violation of a minor" msgid "Privacy violation of a minor"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2301 #: src/view/com/composer/Composer.tsx:2312
msgid "Processing GIF..."
msgstr ""
#: src/view/com/composer/Composer.tsx:2314
msgid "Processing video..." msgid "Processing video..."
msgstr "" msgstr ""
@@ -8523,7 +8556,7 @@ msgstr ""
msgid "Select your preferred notification channels" msgid "Select your preferred notification channels"
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:396 #: src/view/com/composer/SelectMediaButton.tsx:414
msgid "Selecting multiple media types is not supported." msgid "Selecting multiple media types is not supported."
msgstr "" msgstr ""
@@ -8785,7 +8818,7 @@ msgstr ""
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:183 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:81
msgid "Show alt text" msgid "Show alt text"
msgstr "" msgstr ""
@@ -9018,6 +9051,10 @@ msgstr ""
msgid "Snoozes the reminder" msgid "Snoozes the reminder"
msgstr "" msgstr ""
#: src/view/com/composer/drafts/DraftsListDialog.tsx:148
msgid "So many thoughts, you should post one"
msgstr ""
#: src/components/WelcomeModal.tsx:150 #: src/components/WelcomeModal.tsx:150
msgid "Social media you control." msgid "Social media you control."
msgstr "" msgstr ""
@@ -10022,7 +10059,7 @@ msgstr ""
msgid "Toggle to enable or disable adult content" msgid "Toggle to enable or disable adult content"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:169
msgid "Toggles the sound" msgid "Toggles the sound"
msgstr "" msgstr ""
@@ -10263,7 +10300,7 @@ msgstr ""
msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgid "Unlike ({0, plural, one {# like} other {# likes}})"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:152 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:166
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:96 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:96
msgctxt "video" msgctxt "video"
msgid "Unmute" msgid "Unmute"
@@ -10300,7 +10337,7 @@ msgstr ""
msgid "Unmute thread" msgid "Unmute thread"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:317 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:334
msgid "Unmute video" msgid "Unmute video"
msgstr "" msgstr ""
@@ -10438,16 +10475,20 @@ msgstr ""
msgid "Upload from Library" msgid "Upload from Library"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2305
msgid "Uploading GIF..."
msgstr ""
#: src/lib/api/index.ts:302 #: src/lib/api/index.ts:302
msgid "Uploading images..." msgid "Uploading images..."
msgstr "" msgstr ""
#: src/lib/api/index.ts:368 #: src/lib/api/index.ts:370
#: src/lib/api/index.ts:392 #: src/lib/api/index.ts:394
msgid "Uploading link thumbnail..." msgid "Uploading link thumbnail..."
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2298 #: src/view/com/composer/Composer.tsx:2307
msgid "Uploading video..." msgid "Uploading video..."
msgstr "" msgstr ""
@@ -10676,8 +10717,8 @@ msgstr ""
msgid "Version {0}" msgid "Version {0}"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:81 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:84
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:131 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:145
msgid "Video" msgid "Video"
msgstr "" msgstr ""
@@ -10710,7 +10751,7 @@ msgstr ""
msgid "Video is playing" msgid "Video is playing"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:219 #: src/components/Post/Embed/VideoEmbed/index.web.tsx:232
msgid "Video not found." msgid "Video not found."
msgstr "" msgstr ""
@@ -10718,11 +10759,11 @@ msgstr ""
msgid "Video settings" msgid "Video settings"
msgstr "" msgstr ""
#: src/view/com/composer/Composer.tsx:2308 #: src/view/com/composer/Composer.tsx:2325
msgid "Video uploaded" msgid "Video uploaded"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:81 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:84
msgid "Video: {0}" msgid "Video: {0}"
msgstr "" msgstr ""
@@ -10730,7 +10771,7 @@ msgstr ""
msgid "Videos" msgid "Videos"
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:410 #: src/view/com/composer/SelectMediaButton.tsx:428
msgid "Videos must be less than 3 minutes long." msgid "Videos must be less than 3 minutes long."
msgstr "" msgstr ""
@@ -11345,11 +11386,11 @@ msgstr ""
msgid "You can now sign in with your new password." msgid "You can now sign in with your new password."
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:413 #: src/view/com/composer/SelectMediaButton.tsx:431
msgid "You can only select one GIF at a time." msgid "You can only select one GIF at a time."
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:407 #: src/view/com/composer/SelectMediaButton.tsx:425
msgid "You can only select one video at a time." msgid "You can only select one video at a time."
msgstr "" msgstr ""
@@ -11358,7 +11399,7 @@ msgid "You can reactivate your account to continue logging in. Your profile and
msgstr "" msgstr ""
#. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change. #. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.
#: src/view/com/composer/SelectMediaButton.tsx:399 #: src/view/com/composer/SelectMediaButton.tsx:417
msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total." msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total."
msgstr "" msgstr ""
@@ -11547,7 +11588,7 @@ msgstr ""
msgid "You must grant access to your photo library to save a QR code" msgid "You must grant access to your photo library to save a QR code"
msgstr "" msgstr ""
#: src/view/com/composer/SelectMediaButton.tsx:442 #: src/view/com/composer/SelectMediaButton.tsx:460
msgid "You need to allow access to your media library." msgid "You need to allow access to your media library."
msgstr "" msgstr ""
@@ -11723,7 +11764,7 @@ msgstr ""
msgid "Your birth date" msgid "Your birth date"
msgstr "" msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:223 #: src/components/Post/Embed/VideoEmbed/index.web.tsx:236
msgid "Your browser does not support the video format. Please try a different browser." msgid "Your browser does not support the video format. Please try a different browser."
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -14,6 +14,7 @@ export enum Nux {
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner', FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
LiveNowBetaDialog = 'LiveNowBetaDialog', LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge', LiveNowBetaNudge = 'LiveNowBetaNudge',
DraftsAnnouncement = 'DraftsAnnouncement',
/* /*
* Blocking announcements. New IDs are required for each new announcement. * Blocking announcements. New IDs are required for each new announcement.
@@ -72,6 +73,10 @@ export type AppNux = BaseNux<
id: Nux.LiveNowBetaNudge id: Nux.LiveNowBetaNudge
data: undefined data: undefined
} }
| {
id: Nux.DraftsAnnouncement
data: undefined
}
> >
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -87,4 +92,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.FindContactsDismissibleBanner]: undefined, [Nux.FindContactsDismissibleBanner]: undefined,
[Nux.LiveNowBetaDialog]: undefined, [Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined, [Nux.LiveNowBetaNudge]: undefined,
[Nux.DraftsAnnouncement]: undefined,
} }
+18
View File
@@ -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':
if (isGif) {
text = _(msg`Compressing GIF...`)
} else {
text = _(msg`Compressing video...`) text = _(msg`Compressing video...`)
}
break break
case 'uploading': case 'uploading':
if (isGif) {
text = _(msg`Uploading GIF...`)
} else {
text = _(msg`Uploading video...`) text = _(msg`Uploading video...`)
}
break break
case 'processing': case 'processing':
if (isGif) {
text = _(msg`Processing GIF...`)
} else {
text = _(msg`Processing video...`) 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':
if (isGif) {
text = _(msg`GIF uploaded`)
} else {
text = _(msg`Video uploaded`) text = _(msg`Video uploaded`)
}
break break
} }
+22 -4
View File
@@ -1,5 +1,6 @@
import {useCallback, useEffect, useRef} from 'react' import {useCallback, useEffect, useRef} from 'react'
import {Keyboard} from 'react-native' import {Keyboard} from 'react-native'
import {File} from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -18,6 +19,7 @@ import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import * as toast from '#/components/Toast' import * as toast from '#/components/Toast'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
export type SelectMediaButtonProps = { export type SelectMediaButtonProps = {
disabled?: boolean disabled?: boolean
@@ -128,7 +130,7 @@ const extensionToMimeType: Record<
* `mimeType`. If `mimeType` is not available, we try to infer it through * `mimeType`. If `mimeType` is not available, we try to infer it through
* various means. * various means.
*/ */
function classifyImagePickerAsset(asset: ImagePickerAsset): async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
| { | {
success: true success: true
type: AssetType type: AssetType
@@ -138,7 +140,8 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
success: false success: false
type: undefined type: undefined
mimeType: undefined mimeType: undefined
} { }
> {
/* /*
* Try to use the `mimeType` reported by `expo-image-picker` first. * Try to use the `mimeType` reported by `expo-image-picker` first.
*/ */
@@ -178,7 +181,22 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
*/ */
let type: AssetType | undefined let type: AssetType | undefined
if (mimeType === 'image/gif') { if (mimeType === 'image/gif') {
let bytes: ArrayBuffer | undefined
if (IS_WEB) {
bytes = await asset.file?.arrayBuffer()
} else {
const file = new File(asset.uri)
if (file.exists) {
bytes = await file.arrayBuffer()
}
}
if (bytes) {
const {isAnimated} = isAnimatedGif(bytes)
type = isAnimated ? 'gif' : 'image'
} else {
// If we can't read the file, assume it's animated
type = 'gif' type = 'gif'
}
} else if (mimeType?.startsWith('video/')) { } else if (mimeType?.startsWith('video/')) {
type = 'video' type = 'video'
} else if (mimeType?.startsWith('image/')) { } else if (mimeType?.startsWith('image/')) {
@@ -236,7 +254,7 @@ async function processImagePickerAssets(
let supportedAssets: ValidatedImagePickerAsset[] = [] let supportedAssets: ValidatedImagePickerAsset[] = []
for (const asset of assets) { for (const asset of assets) {
const {success, type, mimeType} = classifyImagePickerAsset(asset) const {success, type, mimeType} = await classifyImagePickerAsset(asset)
if (!success) { if (!success) {
errors.add(SelectedAssetError.Unsupported) errors.add(SelectedAssetError.Unsupported)
@@ -469,7 +487,7 @@ export function SelectMediaButton({
useEffect(() => { useEffect(() => {
if (autoOpen && !hasAutoOpened.current && !disabled) { if (autoOpen && !hasAutoOpened.current && !disabled) {
hasAutoOpened.current = true hasAutoOpened.current = true
onPressSelectMedia() void onPressSelectMedia()
} }
}, [autoOpen, disabled, onPressSelectMedia]) }, [autoOpen, disabled, onPressSelectMedia])
@@ -11,6 +11,7 @@ import * as Dialog from '#/components/Dialog'
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX' import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {DraftItem} from './DraftItem' import {DraftItem} from './DraftItem'
@@ -140,13 +141,22 @@ export function DraftsListDialog({
const footerComponent = useMemo( const footerComponent = useMemo(
() => ( () => (
<>
{drafts.length > 5 && (
<View style={[a.align_center, a.py_2xl]}>
<Text style={[a.text_center, t.atoms.text_contrast_medium]}>
<Trans>So many thoughts, you should post one</Trans>
</Text>
</View>
)}
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
style={[a.border_transparent]} style={[a.border_transparent]}
/> />
</>
), ),
[isFetchingNextPage, hasNextPage], [isFetchingNextPage, hasNextPage, drafts.length, t],
) )
return ( return (
@@ -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
@@ -0,0 +1,59 @@
/**
* Checks if a GIF is animated. Cooked up by Claude, validated with some examples.
* @param bytes - The GIF bytes, as a Uint8Array.
* @returns An object with properties isGif, isAnimated, and frames.
*/
export function isAnimatedGif(buffer: ArrayBuffer): {
isGif: boolean
isAnimated: boolean
frames: number
} {
const bytes = new Uint8Array(buffer)
// Verify GIF signature
const sig = String.fromCharCode(...bytes.slice(0, 6))
if (!sig.startsWith('GIF'))
return {isGif: false, isAnimated: false, frames: 0}
let i = 13 // Skip header + logical screen descriptor
// Skip global color table if present
if (bytes[10] & 0x80) {
const gctSize = 3 * (1 << ((bytes[10] & 0x07) + 1))
i += gctSize
}
let frames = 0
while (i < bytes.length) {
const block = bytes[i++]
if (block === 0x2c) {
// Image descriptor
frames++
// Skip image descriptor fields
i += 8
// Skip local color table if present
if (bytes[i] & 0x80) {
const lctSize = 3 * (1 << ((bytes[i] & 0x07) + 1))
i += lctSize + 1
} else {
i++
}
// Skip image data blocks
i++ // LZW minimum code size
while (bytes[i]) i += bytes[i] + 1
i++
} else if (block === 0x21) {
// Extension
i++ // Extension type
while (bytes[i]) i += bytes[i] + 1
i++
} else if (block === 0x3b) {
// Trailer
break
}
}
return {isGif: true, isAnimated: frames > 1, frames}
}