diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts index 13b5b83f..b6ca5abb 100644 --- a/src/lib/hooks/useDedupe.ts +++ b/src/lib/hooks/useDedupe.ts @@ -1,17 +1,20 @@ import React from 'react' -export const useDedupe = () => { +export const useDedupe = (timeout = 250) => { const canDo = React.useRef(true) - return React.useCallback((cb: () => unknown) => { - if (canDo.current) { - canDo.current = false - setTimeout(() => { - canDo.current = true - }, 250) - cb() - return true - } - return false - }, []) + return React.useCallback( + (cb: () => unknown) => { + if (canDo.current) { + canDo.current = false + setTimeout(() => { + canDo.current = true + }, timeout) + cb() + return true + } + return false + }, + [timeout], + ) } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index c62ac5ed..79dd2f49 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -7,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useScrollHandlers} from '#/lib/ScrollContext' import {useDedupe} from 'lib/hooks/useDedupe' import {addStyle} from 'lib/styles' +import {isIOS} from 'platform/detection' import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {FlatList_INTERNAL} from './Views' @@ -49,7 +50,7 @@ function ListImpl( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') - const dedupe = useDedupe() + const dedupe = useDedupe(400) function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -68,6 +69,7 @@ function ListImpl( onBeginDragFromContext?.(e, ctx) }, onEndDrag(e, ctx) { + runOnJS(updateActiveViewAsync)() onEndDragFromContext?.(e, ctx) }, onScroll(e, ctx) { @@ -81,11 +83,14 @@ function ListImpl( } } - runOnJS(dedupe)(updateActiveViewAsync) + if (isIOS) { + runOnJS(dedupe)(updateActiveViewAsync) + } }, // Note: adding onMomentumBegin here makes simulator scroll // lag on Android. So either don't add it, or figure out why. onMomentumEnd(e, ctx) { + runOnJS(updateActiveViewAsync)() onMomentumEndFromContext?.(e, ctx) }, }) diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx index bdc7967c..da8c7a98 100644 --- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx @@ -6,7 +6,7 @@ import {isNative} from '#/platform/detection' const Context = React.createContext<{ activeSource: string activeViewId: string | undefined - setActiveSource: (src: string, viewId: string) => void + setActiveSource: (src: string | null, viewId: string | null) => void player: VideoPlayer } | null>(null) @@ -21,12 +21,13 @@ export function Provider({children}: {children: React.ReactNode}) { const player = useVideoPlayer(activeSource, p => { p.muted = true p.loop = true + // We want to immediately call `play` so we get the loading state p.play() }) - const setActiveSourceOuter = (src: string, viewId: string) => { - setActiveSource(src) - setActiveViewId(viewId) + const setActiveSourceOuter = (src: string | null, viewId: string | null) => { + setActiveSource(src ? src : '') + setActiveViewId(viewId ? viewId : '') } return ( diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 7db25b4d..e5457555 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useId, useState} from 'react' import {View} from 'react-native' -import {Image} from 'expo-image' +import {ImageBackground} from 'expo-image' import {PlayerError, VideoPlayerStatus} from 'expo-video' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react' import {clamp} from '#/lib/numbers' import {useGate} from '#/lib/statsig/statsig' +import {useAutoplayDisabled} from 'state/preferences' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {atoms as a} from '#/alf' import {Button} from '#/components/Button' @@ -69,18 +70,20 @@ function InnerWrapper({embed}: Props) { const viewId = useId() const [playerStatus, setPlayerStatus] = useState< - VideoPlayerStatus | 'switching' - >('loading') + VideoPlayerStatus | 'paused' + >(player.playing ? 'readyToPlay' : 'paused') const [isMuted, setIsMuted] = useState(player.muted) const [isFullscreen, setIsFullscreen] = React.useState(false) const [timeRemaining, setTimeRemaining] = React.useState(0) + const disableAutoplay = useAutoplayDisabled() const isActive = embed.playlist === activeSource && activeViewId === viewId + // There are some different loading states that we should pay attention to and show a spinner for const isLoading = isActive && (playerStatus === 'waitingToPlayAtSpecifiedRate' || playerStatus === 'loading') - const isSwitching = playerStatus === 'switching' - const showOverlay = !isActive || isLoading || isSwitching + // This happens whenever the visibility view decides that another video should start playing + const showOverlay = !isActive || isLoading || playerStatus === 'paused' // send error up to error boundary const [error, setError] = useState(null) @@ -102,11 +105,14 @@ function InnerWrapper({embed}: Props) { ) const statusSub = player.addListener( 'statusChange', - (status, _oldStatus, playerError) => { + (status, oldStatus, playerError) => { setPlayerStatus(status) if (status === 'error') { setError(playerError ?? new Error('Unknown player error')) } + if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') { + player.play() + } }, ) return () => { @@ -115,35 +121,47 @@ function InnerWrapper({embed}: Props) { statusSub.remove() } } - }, [player, isActive]) + }, [player, isActive, disableAutoplay]) - useEffect(() => { - if (!isActive && playerStatus !== 'loading') { - setPlayerStatus('loading') - } - }, [isActive, playerStatus]) - - const onChangeStatus = (isVisible: boolean) => { - if (isFullscreen) { + // The source might already be active (for example, if you are scrolling a list of quotes and its all the same + // video). In those cases, just start playing. Otherwise, setting the active source will result in the video + // start playback immediately + const startPlaying = (ignoreAutoplayPreference: boolean) => { + if (disableAutoplay && !ignoreAutoplayPreference) { return } - if (isVisible) { - setActiveSource(embed.playlist, viewId) - if (!player.playing) { - player.play() - } + if (isActive) { + player.play() } else { - setPlayerStatus('switching') - player.muted = true - if (player.playing) { - player.pause() + setActiveSource(embed.playlist, viewId) + } + } + + const onVisibilityStatusChange = (isVisible: boolean) => { + // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change + // events + if (isFullscreen) { + return + } + if (isVisible) { + startPlaying(false) + } else { + // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted + // until it gets replaced by another video + if (disableAutoplay) { + setActiveSource(null, null) + } else { + player.muted = true + if (player.playing) { + player.pause() + } } } } return ( - + {isActive ? ( ) : null} - - {embed.alt} + ]} + cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android + > - + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index b747223b..31e86303 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -67,9 +67,6 @@ export function VideoEmbedInnerNative({ PlatformInfo.setAudioActive(false) player.muted = true player.playbackRate = 1 - if (!player.playing) { - player.play() - } setIsFullscreen(false) }} accessibilityLabel={