import React, {useCallback, useMemo, useRef, useState} from 'react' import { LayoutAnimation, ListRenderItem, ScrollView, View, ViewToken, } from 'react-native' import { Gesture, GestureDetector, NativeGesture, } from 'react-native-gesture-handler' import Animated, { runOnJS, runOnUI, SharedValue, useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated' import { SafeAreaView, useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context' import {useEvent, useEventListener} from 'expo' import {Image, ImageStyle} from 'expo-image' import {LinearGradient} from 'expo-linear-gradient' import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' import { AppBskyEmbedVideo, AppBskyFeedDefs, AppBskyFeedPost, AtUri, ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import { RouteProp, useFocusEffect, useIsFocused, useNavigation, useRoute, } from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {HITSLOP_20} from '#/lib/constants' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {isAndroid} from '#/platform/detection' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import { FeedFeedbackProvider, useFeedFeedbackContext, } from '#/state/feed-feedback' import {useFeedFeedback} from '#/state/feed-feedback' import {usePostLikeMutationQueue} from '#/state/queries/post' import { AuthorFilter, FeedPostSliceItem, usePostFeedQuery, } from '#/state/queries/post-feed' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useSetLightStatusBar} from '#/state/shell/light-status-bar' import {List} from '#/view/com/util/List' import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Header} from '#/screens/VideoFeed/components/Header' import {atoms as a, platform, ThemeProvider, tokens, useTheme} from '#/alf' import {setNavigationBar} from '#/alf/util/navigationBar' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {ListFooter} from '#/components/Lists' import * as Hider from '#/components/moderation/Hider' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' function createThreeVideoPlayers( sources?: [string, string, string], ): [VideoPlayer, VideoPlayer, VideoPlayer] { // android is typically slower and can't keep up with a 0.1 interval const eventInterval = platform({ ios: 0.2, android: 0.5, default: 0, }) const p1 = createVideoPlayer(sources?.[0] ?? '') p1.loop = true p1.timeUpdateEventInterval = eventInterval const p2 = createVideoPlayer(sources?.[1] ?? '') p2.loop = true p2.timeUpdateEventInterval = eventInterval const p3 = createVideoPlayer(sources?.[2] ?? '') p3.loop = true p3.timeUpdateEventInterval = eventInterval return [p1, p2, p3] } export function VideoFeed({}: NativeStackScreenProps< CommonNavigatorParams, 'VideoFeed' >) { const {top} = useSafeAreaInsets() const {params} = useRoute>() const t = useTheme() const setMinShellMode = useSetMinimalShellMode() useFocusEffect( useCallback(() => { setMinShellMode(true) setNavigationBar('lightbox', t) return () => { setMinShellMode(false) setNavigationBar('theme', t) } }, [setMinShellMode, t]), ) const isFocused = useIsFocused() useSetLightStatusBar(isFocused) return (
) } type CurrentSource = { source: string moderation?: ModerationDecision } | null function Feed() { const {params} = useRoute>() const isFocused = useIsFocused() const {hasSession} = useSession() const feedDesc = useMemo(() => { switch (params.type) { case 'feedgen': return `feedgen|${params.uri as string}` as const case 'author': return `author|${params.did as string}|${ params.filter as AuthorFilter }` as const default: throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) } }, [params]) const feedFeedback = useFeedFeedback(feedDesc, hasSession) const { data, isFetching, refetch, hasNextPage, isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(feedDesc) const videos = React.useMemo(() => { let vids = data?.pages .flatMap(page => page.slices.flatMap(slice => slice.items)) .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) || [] const startingVideoIndex = vids?.findIndex(video => { return video.post.uri === params.initialPostUri }) if (vids && startingVideoIndex && startingVideoIndex > -1) { vids = vids.slice(startingVideoIndex) } return vids }, [data, params.initialPostUri]) const [currentSources, setCurrentSources] = useState< [CurrentSource, CurrentSource, CurrentSource] >([null, null, null]) const [players, setPlayers] = useState< [VideoPlayer, VideoPlayer, VideoPlayer] | null >(null) const [currentIndex, setCurrentIndex] = useState(0) const scrollGesture = useMemo(() => Gesture.Native(), []) const renderItem: ListRenderItem = useCallback( ({item, index}) => { const {post} = item // filtered above, here for TS if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) { return null } const player = players?.[index % 3] const currentSource = currentSources[index % 3] if (!currentSource?.moderation) { return null } return ( ) }, [players, currentIndex, isFocused, currentSources, scrollGesture], ) const updateVideoState = useNonReactiveCallback((index?: number) => { if (!videos.length) return if (index === undefined) { index = currentIndex } else { setCurrentIndex(index) } setCurrentSources(oldSources => { const currentSources = [...oldSources] as [ CurrentSource, CurrentSource, CurrentSource, ] const prevSlice = videos.at(index - 1) const prevPost = prevSlice?.post const prevEmbed = prevPost?.embed const prevVideo = prevEmbed && AppBskyEmbedVideo.isView(prevEmbed) ? prevEmbed.playlist : null const prevVideoModeration = prevSlice?.moderation const currSlice = videos.at(index) const currPost = currSlice?.post const currEmbed = currPost?.embed const currVideo = currEmbed && AppBskyEmbedVideo.isView(currEmbed) ? currEmbed.playlist : null const currVideoModeration = currSlice?.moderation const nextSlice = videos.at(index + 1) const nextPost = nextSlice?.post const nextEmbed = nextPost?.embed const nextVideo = nextEmbed && AppBskyEmbedVideo.isView(nextEmbed) ? nextEmbed.playlist : null const nextVideoModeration = nextSlice?.moderation const prevPlayerCurrentSource = currentSources[(index + 2) % 3] const currPlayerCurrentSource = currentSources[index % 3] const nextPlayerCurrentSource = currentSources[(index + 1) % 3] if (!players) { const args = ['', '', ''] satisfies [string, string, string] if (prevVideo) args[(index + 2) % 3] = prevVideo if (currVideo) args[index % 3] = currVideo if (nextVideo) args[(index + 1) % 3] = nextVideo const [player1, player2, player3] = createThreeVideoPlayers(args) setPlayers([player1, player2, player3]) if (currVideo) { const currPlayer = [player1, player2, player3][index % 3] currPlayer.play() } } else { const [player1, player2, player3] = players const prevPlayer = [player1, player2, player3][(index + 2) % 3] const currPlayer = [player1, player2, player3][index % 3] const nextPlayer = [player1, player2, player3][(index + 1) % 3] if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { prevPlayer.replace(prevVideo) } prevPlayer.pause() if (currVideo) { if (currVideo !== currPlayerCurrentSource?.source) { currPlayer.replace(currVideo) } if ( currVideoModeration && currVideoModeration.ui('contentView').blur ) { currPlayer.pause() } else { currPlayer.play() } } if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { nextPlayer.replace(nextVideo) } nextPlayer.pause() } if (prevVideo && prevVideo !== prevPlayerCurrentSource?.source) { currentSources[(index + 2) % 3] = { source: prevVideo, moderation: prevVideoModeration, } } if (currVideo && currVideo !== currPlayerCurrentSource?.source) { // TODO should already been calculated, but just in case // if (!nextVideoModeration && nextPost && moderationOpts) { // nextVideoModeration = moderatePost(nextPost, moderationOpts) // } currentSources[index % 3] = { source: currVideo, moderation: currVideoModeration, } } if (nextVideo && nextVideo !== nextPlayerCurrentSource?.source) { currentSources[(index + 1) % 3] = { source: nextVideo, moderation: nextVideoModeration, } } // use old array if no changes if ( oldSources[0]?.source === currentSources[0]?.source && oldSources[1]?.source === currentSources[1]?.source && oldSources[2]?.source === currentSources[2]?.source ) { return oldSources } return currentSources }) }) useFocusEffect( useCallback(() => { if (!players) { // create players, set sources, start playing updateVideoState() } return () => { if (players) { // manually release players when offscreen players.forEach(p => p.release()) setPlayers(null) } } }, [players, updateVideoState]), ) const onViewableItemsChanged = useCallback( ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { if (viewableItems[0] && viewableItems[0].index !== null) { updateVideoState(viewableItems[0].index) } }, [updateVideoState], ) return ( } onEndReached={() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage() } }} showsVerticalScrollIndicator={false} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={{itemVisiblePercentThreshold: 100}} /> ) } function keyExtractor(item: FeedPostSliceItem) { return item._reactKey } function VideoItem({ player, post, embed, active, scrollGesture, moderation, }: { player?: VideoPlayer post: AppBskyFeedDefs.PostView embed: AppBskyEmbedVideo.View active: boolean scrollGesture: NativeGesture moderation?: ModerationDecision }) { const postShadow = usePostShadow(post) const {width, height} = useSafeAreaFrame() const {onItemSeen} = useFeedFeedbackContext() React.useEffect(() => { let to: NodeJS.Timeout | null = null if (active) { to = setTimeout(() => { onItemSeen(post) }, 1000) } else if (to) { clearTimeout(to) } }, [active, post, onItemSeen]) return ( {postShadow === POST_TOMBSTONE ? ( Post has been deleted ) : ( <> {player && ( )} {moderation && ( )} )} ) } function VideoItemInner({ player, embed, active, }: { player: VideoPlayer embed: AppBskyEmbedVideo.View active: boolean }) { const {status} = useEvent(player, 'statusChange', {status: player.status}) return ( <> {active && player && ( )} ) } function ModerationOverlay({ embed, onPressShow, }: { embed: AppBskyEmbedVideo.View onPressShow: () => void }) { const hider = Hider.useHider() const {_} = useLingui() const onShow = React.useCallback(() => { hider.setIsContentVisible(true) onPressShow() }, [hider, onPressShow]) return ( Hidden by your moderation settings. ) } function Overlay({ player, post, embed, active, scrollGesture, moderation, }: { player?: VideoPlayer post: Shadow embed: AppBskyEmbedVideo.View active: boolean scrollGesture: NativeGesture moderation: ModerationDecision }) { const {_} = useLingui() const t = useTheme() const navigation = useNavigation() const seekingAnimationSV = useSharedValue(0) const profile = useProfileShadow(post.author) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ImmersiveVideo', ) const rkey = new AtUri(post.uri).rkey const record = AppBskyFeedPost.isRecord(post.record) ? post.record : undefined const richText = new RichTextAPI({ text: record?.text || '', facets: record?.facets, }) const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 - seekingAnimationSV.get(), })) const onPressShow = React.useCallback(() => { player?.play() }, [player]) return ( {sanitizeDisplayName( post.author.displayName || post.author.handle, )} {sanitizeHandle(post.author.handle, '@')} {/* show button based on non-reactive version, so it doesn't hide on press */} {!post.author.viewer?.following && ( )} {record?.text?.trim() && ( )} {record && ( navigation.navigate('PostThread', { name: post.author.did, rkey, }) } big /> )} {player && active ? ( ) : ( )} {/* {isAndroid && status === 'loading' && ( )} */} ) } /** * Magic number that matches the Scrubber height */ function ScrubberPlaceholder() { const {bottom} = useSafeAreaInsets() return ( ) } function Scrubber({ player, seekingAnimationSV, scrollGesture, }: { player: VideoPlayer seekingAnimationSV: SharedValue scrollGesture: NativeGesture }) { const {width: screenWidth} = useSafeAreaFrame() const insets = useSafeAreaInsets() const currentTimeSV = useSharedValue(0) const durationSV = useSharedValue(0) const [currentSeekTime, setCurrentSeekTime] = useState(0) const [duration, setDuration] = useState(0) const updateTime = (currentTime: number, duration: number) => { 'worklet' currentTimeSV.set(currentTime) durationSV.set(duration) } useEventListener(player, 'timeUpdate', evt => { runOnUI(updateTime)(evt.currentTime, player.duration) }) const isSeekingSV = useSharedValue(false) const seekProgressSV = useSharedValue(0) useAnimatedReaction( () => Math.round(seekProgressSV.get()), (progress, prevProgress) => { if (progress !== prevProgress) { runOnJS(setCurrentSeekTime)(progress) } }, ) useAnimatedReaction( () => Math.round(durationSV.get()), (duration, prevDuration) => { if (duration !== prevDuration) { runOnJS(setDuration)(duration) } }, ) const seekBy = useCallback( (time: number) => { player.seekBy(time) }, [player], ) const scrubPanGesture = useMemo(() => { return Gesture.Pan() .blocksExternalGesture(scrollGesture) .activeOffsetX([-1, 1]) .failOffsetY([-10, 10]) .onBegin(() => { 'worklet' }) .onStart(() => { 'worklet' seekProgressSV.set(currentTimeSV.get()) isSeekingSV.set(true) seekingAnimationSV.set(withTiming(1, {duration: 500})) }) .onUpdate(evt => { 'worklet' const progress = evt.x / screenWidth seekProgressSV.set( clamp(progress * durationSV.get(), 0, durationSV.get()), ) }) .onEnd(evt => { 'worklet' isSeekingSV.get() const progress = evt.x / screenWidth const newTime = clamp(progress * durationSV.get(), 0, durationSV.get()) // it's seek by, so offset by the current time runOnJS(seekBy)(newTime - currentTimeSV.get()) isSeekingSV.set(false) seekingAnimationSV.set(withTiming(0, {duration: 500})) }) }, [ scrollGesture, seekingAnimationSV, seekBy, screenWidth, currentTimeSV, durationSV, isSeekingSV, seekProgressSV, ]) const timeStyle = useAnimatedStyle(() => { return { display: seekingAnimationSV.get() === 0 ? 'none' : 'flex', opacity: seekingAnimationSV.get(), } }) const barStyle = useAnimatedStyle(() => { const currentTime = isSeekingSV.get() ? seekProgressSV.get() : currentTimeSV.get() const progress = currentTime === 0 ? 0 : currentTime / durationSV.get() return { height: seekingAnimationSV.get() * 3 + 1, width: `${progress * 100}%`, } }) return ( <> {formatTime(currentSeekTime)} {' / '} {formatTime(duration)} ) } function ExpandableRichTextView({ value, authorHandle, }: { value: RichTextAPI authorHandle?: string }) { const {height: screenHeight} = useSafeAreaFrame() const [expanded, setExpanded] = useState(false) const [constrained, setConstrained] = useState(false) const [contentHeight, setContentHeight] = useState(0) const {_} = useLingui() return ( { if (expanded) { LayoutAnimation.configureNext({ duration: 500, update: {type: 'spring', springDamping: 0.6}, }) } setContentHeight(h) }} style={{height: Math.min(contentHeight, screenHeight * 0.5)}} contentContainerStyle={[ a.gap_xs, expanded ? [a.align_start] : a.flex_row, ]}> { if (!constrained && evt.nativeEvent.lines.length > 1) { setConstrained(true) } }} /> {constrained && ( )} ) } function VideoItemPlaceholder({ embed, style, blur, }: { embed: AppBskyEmbedVideo.View style?: ImageStyle blur?: boolean }) { const src = embed.thumbnail let contentFit = isTallAspectRatio(embed.aspectRatio) ? ('cover' as const) : ('contain' as const) if (blur) { contentFit = 'cover' as const } return src ? ( ) : null } function PlayPauseTapArea({ player, post, }: { player?: VideoPlayer post: Shadow }) { const doubleTapRef = useRef | null>(null) const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo') const {sendInteraction} = useFeedFeedbackContext() const togglePlayPause = () => { if (!player) return doubleTapRef.current = null if (player.playing) { player.pause() } else { player.play() } } const onPress = () => { if (doubleTapRef.current) { clearTimeout(doubleTapRef.current) doubleTapRef.current = null queueLike() sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionLike', }) } else { doubleTapRef.current = setTimeout(togglePlayPause, 200) } } return ( ) } function clamp(num: number, min: number, max: number) { 'worklet' return Math.min(Math.max(num, min), max) } /* * If the video is taller than 9:16 */ function isTallAspectRatio(aspectRatio: AppBskyEmbedVideo.View['aspectRatio']) { const videoAspectRatio = (aspectRatio?.width ?? 1) / (aspectRatio?.height ?? 1) return videoAspectRatio <= 9 / 16 }