import {useCallback, useMemo, useRef, useState} from 'react' import { LayoutAnimation, ListRenderItem, ScrollView, useWindowDimensions, View, ViewToken, } from 'react-native' import {Gesture, GestureDetector} from 'react-native-gesture-handler' import {runOnJS} from 'react-native-reanimated' import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context' import {useEvent} from 'expo' import {Image} from 'expo-image' import {LinearGradient} from 'expo-linear-gradient' import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video' import { AppBskyEmbedVideo, AppBskyFeedDefs, AppBskyFeedPost, AtUri, RichText as RichTextAPI, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import { useFocusEffect, useIsFocused, useNavigation, } from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {HITSLOP_20, VIBES_FEED_URI} 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 {usePostLikeMutationQueue} from '#/state/queries/post' import {FeedPostSliceItem, usePostFeedQuery} from '#/state/queries/post-feed' 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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import {Loader} from '#/components/Loader' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' function createThreeVideoPlayers( sources?: [string, string, string], ): [VideoPlayer, VideoPlayer, VideoPlayer] { const p1 = createVideoPlayer(sources?.[0] ?? '') p1.loop = true const p2 = createVideoPlayer(sources?.[1] ?? '') p2.loop = true const p3 = createVideoPlayer(sources?.[2] ?? '') p3.loop = true return [p1, p2, p3] } type Props = NativeStackScreenProps export function VibeScreen({}: Props) { const {top} = useSafeAreaInsets() const setMinShellMode = useSetMinimalShellMode() useFocusEffect( useCallback(() => { setMinShellMode(true) return () => { setMinShellMode(false) } }, [setMinShellMode]), ) useSetLightStatusBar(true) return ( {/* TODO: needs to be feed name */} Vibes (wip) ) } function YoloFeed() { const isFocused = useIsFocused() const { data, isFetching, refetch, hasNextPage, isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(`feedgen|${VIBES_FEED_URI}`) const videos = data?.pages.flatMap(page => page.slices.flatMap(slice => slice.items), ) const [currentSources, setCurrentSources] = useState< [string | null, string | null, string | null] >([null, null, null]) const [players, setPlayers] = useState< [VideoPlayer, VideoPlayer, VideoPlayer] | null >(createThreeVideoPlayers) const [currentIndex, setCurrentIndex] = useState(0) const renderItem: ListRenderItem = useCallback( ({item, index}) => { const {post} = item if (!post.embed || !AppBskyEmbedVideo.isView(post.embed)) { return null } const player = players?.[index % 3] const currentSource = currentSources[index % 3] return ( ) }, [players, currentIndex, isFocused, currentSources], ) const updateVideoState = useNonReactiveCallback((index?: number) => { if (!videos) return if (index === undefined) { index = currentIndex } else { setCurrentIndex(index) } setCurrentSources(oldSources => { const currentSources = [...oldSources] as [ string | null, string | null, string | null, ] const prevEmbed = videos[index - 1]?.post.embed const prevVideo = prevEmbed && AppBskyEmbedVideo.isView(prevEmbed) ? prevEmbed.playlist : null const currEmbed = videos[index]?.post.embed const currVideo = currEmbed && AppBskyEmbedVideo.isView(currEmbed) ? currEmbed.playlist : null const nextEmbed = videos[index + 1]?.post.embed const nextVideo = nextEmbed && AppBskyEmbedVideo.isView(nextEmbed) ? nextEmbed.playlist : null 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) { prevPlayer.replace(prevVideo) } prevPlayer.pause() if (currVideo) { if (currVideo !== currPlayerCurrentSource) { currPlayer.replace(currVideo) } currPlayer.play() } if (nextVideo && nextVideo !== nextPlayerCurrentSource) { nextPlayer.replace(nextVideo) } nextPlayer.pause() } if (prevVideo && prevVideo !== prevPlayerCurrentSource) { currentSources[(index + 2) % 3] = prevVideo } if (currVideo && currVideo !== currPlayerCurrentSource) { currentSources[index % 3] = currVideo } if (nextVideo && nextVideo !== nextPlayerCurrentSource) { currentSources[(index + 1) % 3] = nextVideo } // use old array if no changes if ( oldSources[0] === currentSources[0] && oldSources[1] === currentSources[1] && oldSources[2] === currentSources[2] ) { 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: 95}} /> ) } function keyExtractor(item: FeedPostSliceItem) { return item._reactKey } function VibeItem({ player, post, embed, active, }: { player?: VideoPlayer post: AppBskyFeedDefs.PostView embed: AppBskyEmbedVideo.View active: boolean }) { const postShadow = usePostShadow(post) const {height, width} = useWindowDimensions() const insets = useSafeAreaInsets() return ( {player ? ( ) : ( embed.thumbnail && ( ) )} {postShadow !== POST_TOMBSTONE ? ( player && ) : ( Post has been deleted )} ) } function VibeItemInner({ player, embed, active, }: { player: VideoPlayer embed: AppBskyEmbedVideo.View active: boolean }) { const insets = useSafeAreaInsets() const {status} = useEvent(player, 'statusChange', {status: player.status}) const videoAspectRatio = (embed.aspectRatio?.width ?? 1) / (embed.aspectRatio?.height ?? 1) // if the video tall enough (tiktok/reels are 9:16) go cover mode const isCloseEnough = videoAspectRatio <= 9 / 16 return ( <> {active && player && ( )} {embed.thumbnail && ( )} ) } function VibeOverlay({ player, post, }: { player: VideoPlayer post: Shadow }) { const insets = useSafeAreaInsets() const t = useTheme() const navigation = useNavigation() const [expanded, setExpanded] = useState(false) const {status} = useEvent(player, 'statusChange', {status: player.status}) const doubleTapRef = useRef | null>(null) const [queueLike] = usePostLikeMutationQueue(post, 'Vibe') const pushToProfile = useNonReactiveCallback(() => { navigation.navigate('Profile', {name: post.author.did}) }) const togglePlayPause = () => { if (player.playing) { player.pause() } else { player.play() } } const onPress = () => { if (doubleTapRef.current) { clearTimeout(doubleTapRef.current) doubleTapRef.current = null queueLike() } else { doubleTapRef.current = setTimeout(togglePlayPause, 300) } } const gesture = useMemo(() => { const dragLeftGesture = Gesture.Pan() .activeOffsetX([0, 10]) .failOffsetX([-10, 0]) .failOffsetY([-5, 5]) .maxPointers(1) .onEnd(evt => { 'worklet' if (evt.translationX < -50) { runOnJS(pushToProfile)() } }) return dragLeftGesture }, [pushToProfile]) 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, }) return ( <> {sanitizeDisplayName( post.author.displayName || post.author.handle, )} {sanitizeHandle(post.author.handle, '@')} {record?.text?.trim() && ( )} {record && ( navigation.navigate('PostThread', { name: post.author.did, rkey, }) } big /> )} {isAndroid && status === 'loading' && ( )} ) } function ExpandableRichTextView({ value, authorHandle, expanded, setExpanded, }: { value: RichTextAPI authorHandle?: string expanded: boolean setExpanded: React.Dispatch> }) { const {height: screenHeight} = useWindowDimensions() 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 && ( )} ) }