diff --git a/app.config.js b/app.config.js index cab8f83489..37f17b7510 100644 --- a/app.config.js +++ b/app.config.js @@ -313,22 +313,22 @@ module.exports = function (_config) { { ios: { enableFullScreenImage_legacy: true, // iOS only - backgroundColor: '#A8CCFF', // primary_200 + backgroundColor: '#006AFF', // primary_500 image: './assets/splash/splash.png', resizeMode: 'cover', dark: { enableFullScreenImage_legacy: true, // iOS only - backgroundColor: '#00398A', // primary_800 + backgroundColor: '#002861', // primary_900 image: './assets/splash/splash-dark.png', resizeMode: 'cover', }, }, android: { - backgroundColor: '#A8CCFF', // primary_200 + backgroundColor: '#006AFF', // primary_500 image: './assets/splash/android-splash-logo-white.png', imageWidth: 102, // even division of 306px dark: { - backgroundColor: '#00398A', // primary_800 + backgroundColor: '#002861', // primary_900 image: './assets/splash/android-splash-logo-white.png', imageWidth: 102, }, diff --git a/assets/images/drafts_announcement_nux.webp b/assets/images/drafts_announcement_nux.webp new file mode 100644 index 0000000000..301589690c Binary files /dev/null and b/assets/images/drafts_announcement_nux.webp differ diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index acd4b313fb..542e546c42 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -50,7 +50,11 @@ export function Embed({ } else if (e.type === 'video') { return ( - + {e.view.presentation === 'gif' ? ( + + ) : ( + + )} ) } else if ( @@ -81,11 +85,29 @@ export function ImageItem({ alt, children, }: { - thumbnail: string + thumbnail?: string alt?: string children?: React.ReactNode }) { const t = useTheme() + + if (!thumbnail) { + return ( + + {children} + + ) + } + return ( {alt} @@ -125,22 +147,6 @@ export function VideoItem({ thumbnail?: string alt?: string }) { - if (!thumbnail) { - return ( - - - - ) - } return ( @@ -157,7 +163,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', - right: 5, + left: 5, bottom: 5, zIndex: 2, }, diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx index 0a455b546e..cc53192c78 100644 --- a/src/components/Post/Embed/ExternalEmbed/Gif.tsx +++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx @@ -1,76 +1,17 @@ import {useRef, useState} from 'react' -import { - Pressable, - type StyleProp, - StyleSheet, - TouchableOpacity, - View, - type ViewStyle, -} from 'react-native' -import {msg, Trans} from '@lingui/macro' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {HITSLOP_20} from '#/lib/constants' import {clamp} from '#/lib/numbers' import {type EmbedPlayerParams} from '#/lib/strings/embed-player' import {useAutoplayDisabled} from '#/state/preferences' -import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {atoms as a, useTheme} from '#/alf' import {Fill} from '#/components/Fill' -import {Loader} from '#/components/Loader' -import * as Prompt from '#/components/Prompt' -import {Text} from '#/components/Typography' -import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -import {IS_WEB} from '#/env' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' - -function PlaybackControls({ - onPress, - isPlaying, - isLoaded, -}: { - onPress: () => void - isPlaying: boolean - isLoaded: boolean -}) { - const {_} = useLingui() - const t = useTheme() - - return ( - - {!isLoaded ? ( - - - - - - ) : !isPlaying ? ( - - ) : undefined} - - ) -} +import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls' export function GifEmbed({ params, @@ -120,8 +61,6 @@ export function GifEmbed({ style={[ a.rounded_md, a.overflow_hidden, - a.border, - t.atoms.border_contrast_low, {backgroundColor: t.palette.black}, {aspectRatio}, style, @@ -139,10 +78,12 @@ export function GifEmbed({ right: -2, }, ]}> - + )} - {!hideAlt && isPreferredAltText && } ) } - -function AltText({text}: {text: string}) { - const control = Prompt.usePromptControl() - const largeAltBadge = useLargeAltBadgeEnabled() - - const {_} = useLingui() - return ( - <> - - - ALT - - - - - - Alt Text - - {text} - - - control.close()} - cta={_(msg`Close`)} - color="secondary" - /> - - - - ) -} - -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', - }, -}) diff --git a/src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx b/src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx new file mode 100644 index 0000000000..42098a7e9c --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx @@ -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 ( + <> + + {isLoading ? ( + + + + ) : !isPlaying ? ( + + ) : undefined} + + {!isPlaying && ( + + )} + + + GIF + + + {altText && } + + ) +} + +function AltBadge({text}: {text: string}) { + const control = Prompt.usePromptControl() + const {_} = useLingui() + + return ( + <> + + + ALT + + + + + + Alt Text + + {text} + + + control.close()} + cta={_(msg`Close`)} + color="secondary" + /> + + + + ) +} + +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, + }, +}) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index ecc36dc33b..9cad63e9a8 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -15,6 +15,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {GifPresentationControls} from '../GifPresentationControls' import {TimeIndicator} from './TimeIndicator' export function VideoEmbedInnerNative({ @@ -50,12 +51,14 @@ export function VideoEmbedInnerNative({ throw new Error(error) } + const isGif = embed.presentation === 'gif' + return ( { setIsActive(e.nativeEvent.isActive) @@ -82,25 +85,36 @@ export function VideoEmbedInnerNative({ } accessibilityHint="" /> - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> + {isGif ? ( + { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + isLoading={false} + altText={embed.alt} + /> + ) : ( + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + )} ) } -function VideoControls({ +function VideoPresentationControls({ enterFullscreen, toggleMuted, togglePlayback, diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index add2a7afb4..5d14161fe1 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({ active: boolean setActive: () => void onScreen: boolean - lastKnownTime: React.MutableRefObject + lastKnownTime: React.RefObject }) { const containerRef = useRef(null) const videoRef = useRef(null) @@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({ throw error } - const hlsRef = useHLS({ + const {hlsRef, loop} = useHLS({ playlist: embed.playlist, setHasSubtitleTrack, setError, @@ -64,11 +64,12 @@ export function VideoEmbedInnerWeb({ style={{width: '100%', height: '100%', objectFit: 'contain'}} playsInline preload="none" - muted={!focused} + muted={embed.presentation === 'gif' || !focused} aria-labelledby={embed.alt ? figId : undefined} onTimeUpdate={e => { lastKnownTime.current = e.currentTarget.currentTime }} + loop={loop} /> {embed.alt && (
@@ -192,29 +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(() => { if (!videoRef.current) return if (!Hls) return @@ -242,20 +222,6 @@ function useHLS({ hls.attachMedia(videoRef.current) hls.loadSource(playlist) - // manually loop, so if we've flushed the first buffer it doesn't get confused - const abortController = new AbortController() - const {signal} = abortController - const videoNode = videoRef.current - videoNode.addEventListener( - 'ended', - () => { - flushOnLoop() - videoNode.currentTime = 0 - videoNode.play() - }, - {signal}, - ) - hls.on(Hls.Events.FRAG_LOADED, () => { BandwidthEstimate.set(hls.bandwidthEstimate) }) @@ -293,17 +259,65 @@ function useHLS({ hlsRef.current = undefined hls.detachMedia() hls.destroy() + } + }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) + + const flushOnLoop = useNonReactiveCallback(() => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + // `handleFragChange` will catch most stale frags, but there's a corner case - + // if there's only one segment in the video, it won't get flushed because it avoids + // flushing the currently active segment. Therefore, we have to catch it when we loop + if ( + hls.nextAutoLevel > 0 && + lowQualityFragments.length === 1 && + lowQualityFragments[0].start === 0 + ) { + const lowQualFrag = lowQualityFragments[0] + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + setLowQualityFragments([]) + } + }) + + // manually loop, so if we've flushed the first buffer it doesn't get confused + const hasLowQualityFragmentAtStart = lowQualityFragments.some( + frag => frag.start === 0, + ) + useEffect(() => { + if (!videoRef.current) return + + // use `loop` prop on `