From d68bc519a376241c3bd0775da57fabb81b0d2d43 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sat, 18 Jan 2025 10:50:30 -0600 Subject: [PATCH] Copy pasta to new location --- src/screens/Feeds/VibeScreen.tsx | 975 ------------------------------ src/screens/VideoFeed/index.tsx | 976 ++++++++++++++++++++++++++++++- 2 files changed, 975 insertions(+), 976 deletions(-) delete mode 100644 src/screens/Feeds/VibeScreen.tsx diff --git a/src/screens/Feeds/VibeScreen.tsx b/src/screens/Feeds/VibeScreen.tsx deleted file mode 100644 index 0182fb43c6..0000000000 --- a/src/screens/Feeds/VibeScreen.tsx +++ /dev/null @@ -1,975 +0,0 @@ -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, - 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/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 {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' -import * as Layout from '#/components/Layout' -import {Link} from '#/components/Link' -import {ListFooter} from '#/components/Lists' -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 ( - - - -
- - - - - ) -} - -function Inner() { - const {params} = useRoute>() - const isFocused = useIsFocused() - 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 { - data, - isFetching, - refetch, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = usePostFeedQuery(feedDesc) - const {hasSession} = useSession() - const feedFeedback = useFeedFeedback(feedDesc, hasSession) - - let videos = data?.pages.flatMap(page => - page.slices.flatMap(slice => slice.items), - ) - const startingVideoIndex = videos?.findIndex(video => { - return video.post.uri === params.initialPostUri - }) - if (videos && startingVideoIndex && startingVideoIndex > -1) { - videos = videos.slice(startingVideoIndex) - } - - 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 scrollGesture = useMemo(() => Gesture.Native(), []) - - 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, scrollGesture], - ) - - 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: 100}} - /> - - - ) -} - -function keyExtractor(item: FeedPostSliceItem) { - return item._reactKey -} - -function VideoItem({ - player, - post, - embed, - active, - scrollGesture, -}: { - player?: VideoPlayer - post: AppBskyFeedDefs.PostView - embed: AppBskyEmbedVideo.View - active: boolean - scrollGesture: NativeGesture -}) { - 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 && ( - - )} - - - )} - - - ) -} - -function VideoItemInner({ - player, - embed, - active, -}: { - player: VideoPlayer - embed: AppBskyEmbedVideo.View - active: boolean -}) { - const {status} = useEvent(player, 'statusChange', {status: player.status}) - - return ( - <> - {active && player && ( - - )} - - ) -} - -function Overlay({ - player, - post, - active, - scrollGesture, -}: { - player?: VideoPlayer - post: Shadow - active: boolean - scrollGesture: NativeGesture -}) { - 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(), - })) - - 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, -}: { - embed: AppBskyEmbedVideo.View - style?: ImageStyle -}) { - const src = embed.thumbnail - 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 -} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index b9412343e4..0182fb43c6 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -1 +1,975 @@ -export {VideoFeed} from '#/screens/Feeds/VibeScreen' +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, + 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/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 {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import * as Layout from '#/components/Layout' +import {Link} from '#/components/Link' +import {ListFooter} from '#/components/Lists' +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 ( + + + +
+ + + + + ) +} + +function Inner() { + const {params} = useRoute>() + const isFocused = useIsFocused() + 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 { + data, + isFetching, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feedDesc) + const {hasSession} = useSession() + const feedFeedback = useFeedFeedback(feedDesc, hasSession) + + let videos = data?.pages.flatMap(page => + page.slices.flatMap(slice => slice.items), + ) + const startingVideoIndex = videos?.findIndex(video => { + return video.post.uri === params.initialPostUri + }) + if (videos && startingVideoIndex && startingVideoIndex > -1) { + videos = videos.slice(startingVideoIndex) + } + + 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 scrollGesture = useMemo(() => Gesture.Native(), []) + + 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, scrollGesture], + ) + + 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: 100}} + /> + + + ) +} + +function keyExtractor(item: FeedPostSliceItem) { + return item._reactKey +} + +function VideoItem({ + player, + post, + embed, + active, + scrollGesture, +}: { + player?: VideoPlayer + post: AppBskyFeedDefs.PostView + embed: AppBskyEmbedVideo.View + active: boolean + scrollGesture: NativeGesture +}) { + 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 && ( + + )} + + + )} + + + ) +} + +function VideoItemInner({ + player, + embed, + active, +}: { + player: VideoPlayer + embed: AppBskyEmbedVideo.View + active: boolean +}) { + const {status} = useEvent(player, 'statusChange', {status: player.status}) + + return ( + <> + {active && player && ( + + )} + + ) +} + +function Overlay({ + player, + post, + active, + scrollGesture, +}: { + player?: VideoPlayer + post: Shadow + active: boolean + scrollGesture: NativeGesture +}) { + 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(), + })) + + 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, +}: { + embed: AppBskyEmbedVideo.View + style?: ImageStyle +}) { + const src = embed.thumbnail + 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 +}