import {useCallback, useMemo, 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 { useVideoPlayer, VideoPlayer, VideoPlayerStatus, 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, usePostShadow} from '#/state/cache/post-shadow' 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' 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 [currentSources, setCurrentSources] = useState< [string | null, string | null, string | null] >([null, null, null]) const player1 = useVideoPlayer('', p => { p.loop = true }) const player2 = useVideoPlayer('', p => { p.loop = true }) const player3 = useVideoPlayer('', p => { p.loop = true }) const videos = data?.pages.flatMap(page => page.slices.flatMap(slice => slice.items), ) 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 = [player1, player2, player3][index % 3] const currentSource = currentSources[index % 3] return ( ) }, [player1, player2, player3, currentIndex, isFocused, currentSources], ) const updateVideoState = useNonReactiveCallback((index: number) => { if (!videos) return 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 prevPlayer = [player1, player2, player3][(index + 2) % 3] const prevPlayerCurrentSource = currentSources[(index + 2) % 3] const currPlayer = [player1, player2, player3][index % 3] const currPlayerCurrentSource = currentSources[index % 3] const nextPlayer = [player1, player2, player3][(index + 1) % 3] const nextPlayerCurrentSource = currentSources[(index + 1) % 3] if (prevVideo && prevVideo !== prevPlayerCurrentSource) { prevPlayer.replace(prevVideo) currentSources[index + (2 % 3)] = prevVideo } prevPlayer.pause() if (currVideo) { if (currVideo !== currPlayerCurrentSource) { currPlayer.replace(currVideo) currentSources[index % 3] = currVideo } currPlayer.play() } if (nextVideo && nextVideo !== nextPlayerCurrentSource) { nextPlayer.replace(nextVideo) currentSources[(index + 1) % 3] = nextVideo } nextPlayer.pause() // use old array if no changes if ( oldSources[0] === currentSources[0] && oldSources[1] === currentSources[1] && oldSources[2] === currentSources[2] ) { return oldSources } return currentSources }) }) 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 {height, width} = useWindowDimensions() const insets = useSafeAreaInsets() 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 = false && videoAspectRatio <= 9 / 16 const {status} = useEvent(player, 'statusChange', {status: player.status}) return ( {active && ( )} {embed.thumbnail && ( )} ) } function VibeOverlay({ player, post, status, }: { player: VideoPlayer post: AppBskyFeedDefs.PostView status: VideoPlayerStatus }) { const postShadow = usePostShadow(post) const insets = useSafeAreaInsets() const t = useTheme() const navigation = useNavigation() const [expanded, setExpanded] = useState(false) const pushToProfile = useNonReactiveCallback(() => { navigation.navigate('Profile', {name: post.author.did}) }) const togglePlayPause = () => { if (player.playing) { player.pause() } else { player.play() } } 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() && ( )} {postShadow !== POST_TOMBSTONE && 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 (contentHeight !== 0) { 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 && ( )} ) }