release video players when leaving screen

This commit is contained in:
Samuel Newman
2025-01-16 14:34:55 +00:00
parent 15856c155d
commit a147d250ef
+168 -53
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo, useState} from 'react' import {useCallback, useMemo, useRef, useState} from 'react'
import { import {
LayoutAnimation, LayoutAnimation,
ListRenderItem, ListRenderItem,
@@ -13,12 +13,7 @@ import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
import {useEvent} from 'expo' import {useEvent} from 'expo'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import { import {createVideoPlayer, VideoPlayer, VideoView} from 'expo-video'
useVideoPlayer,
VideoPlayer,
VideoPlayerStatus,
VideoView,
} from 'expo-video'
import { import {
AppBskyEmbedVideo, AppBskyEmbedVideo,
AppBskyFeedDefs, AppBskyFeedDefs,
@@ -41,7 +36,8 @@ import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {isAndroid} from '#/platform/detection' import {isAndroid} from '#/platform/detection'
import {POST_TOMBSTONE, usePostShadow} from '#/state/cache/post-shadow' 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 {FeedPostSliceItem, usePostFeedQuery} from '#/state/queries/post-feed'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar' import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
@@ -56,6 +52,18 @@ import {Loader} from '#/components/Loader'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' 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<CommonNavigatorParams, 'TempVibe'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'TempVibe'>
export function VibeScreen({}: Props) { export function VibeScreen({}: Props) {
const {top} = useSafeAreaInsets() const {top} = useSafeAreaInsets()
@@ -78,7 +86,7 @@ export function VibeScreen({}: Props) {
<View <View
style={[ style={[
a.absolute, a.absolute,
a.z_10, a.z_30,
{top: 0, left: 0, right: 0, paddingTop: top}, {top: 0, left: 0, right: 0, paddingTop: top},
]}> ]}>
<Layout.Header.Outer noBottomBorder> <Layout.Header.Outer noBottomBorder>
@@ -109,23 +117,17 @@ function YoloFeed() {
fetchNextPage, fetchNextPage,
} = usePostFeedQuery(`feedgen|${VIBES_FEED_URI}`) } = usePostFeedQuery(`feedgen|${VIBES_FEED_URI}`)
const videos = data?.pages.flatMap(page =>
page.slices.flatMap(slice => slice.items),
)
const [currentSources, setCurrentSources] = useState< const [currentSources, setCurrentSources] = useState<
[string | null, string | null, string | null] [string | null, string | null, string | null]
>([null, null, null]) >([null, null, null])
const player1 = useVideoPlayer('', p => { const [players, setPlayers] = useState<
p.loop = true [VideoPlayer, VideoPlayer, VideoPlayer] | null
}) >(createThreeVideoPlayers)
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 [currentIndex, setCurrentIndex] = useState(0)
@@ -136,7 +138,7 @@ function YoloFeed() {
return null return null
} }
const player = [player1, player2, player3][index % 3] const player = players?.[index % 3]
const currentSource = currentSources[index % 3] const currentSource = currentSources[index % 3]
return ( return (
@@ -152,12 +154,18 @@ function YoloFeed() {
/> />
) )
}, },
[player1, player2, player3, currentIndex, isFocused, currentSources], [players, currentIndex, isFocused, currentSources],
) )
const updateVideoState = useNonReactiveCallback((index: number) => { const updateVideoState = useNonReactiveCallback((index?: number) => {
if (!videos) return if (!videos) return
if (index === undefined) {
index = currentIndex
} else {
setCurrentIndex(index) setCurrentIndex(index)
}
setCurrentSources(oldSources => { setCurrentSources(oldSources => {
const currentSources = [...oldSources] as [ const currentSources = [...oldSources] as [
string | null, string | null,
@@ -181,32 +189,59 @@ function YoloFeed() {
? nextEmbed.playlist ? nextEmbed.playlist
: null : null
const prevPlayer = [player1, player2, player3][(index + 2) % 3]
const prevPlayerCurrentSource = currentSources[(index + 2) % 3] const prevPlayerCurrentSource = currentSources[(index + 2) % 3]
const currPlayer = [player1, player2, player3][index % 3]
const currPlayerCurrentSource = currentSources[index % 3] const currPlayerCurrentSource = currentSources[index % 3]
const nextPlayer = [player1, player2, player3][(index + 1) % 3]
const nextPlayerCurrentSource = currentSources[(index + 1) % 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) { if (prevVideo && prevVideo !== prevPlayerCurrentSource) {
prevPlayer.replace(prevVideo) prevPlayer.replace(prevVideo)
currentSources[(index + 2) % 3] = prevVideo
} }
prevPlayer.pause() prevPlayer.pause()
if (currVideo) { if (currVideo) {
if (currVideo !== currPlayerCurrentSource) { if (currVideo !== currPlayerCurrentSource) {
currPlayer.replace(currVideo) currPlayer.replace(currVideo)
currentSources[index % 3] = currVideo
} }
currPlayer.play() currPlayer.play()
} }
if (nextVideo && nextVideo !== nextPlayerCurrentSource) { if (nextVideo && nextVideo !== nextPlayerCurrentSource) {
nextPlayer.replace(nextVideo) nextPlayer.replace(nextVideo)
currentSources[(index + 1) % 3] = nextVideo
} }
nextPlayer.pause() 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 // use old array if no changes
if ( if (
@@ -220,6 +255,20 @@ function YoloFeed() {
}) })
}) })
useFocusEffect(
useCallback(() => {
if (!players) {
updateVideoState()
}
return () => {
if (players) {
players.forEach(p => p.release())
setPlayers(null)
}
}
}, [players, updateVideoState]),
)
const onViewableItemsChanged = useCallback( const onViewableItemsChanged = useCallback(
({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
if (viewableItems[0] && viewableItems[0].index !== null) { if (viewableItems[0] && viewableItems[0].index !== null) {
@@ -266,22 +315,15 @@ function VibeItem({
embed, embed,
active, active,
}: { }: {
player: VideoPlayer player?: VideoPlayer
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
active: boolean active: boolean
}) { }) {
const postShadow = usePostShadow(post)
const {height, width} = useWindowDimensions() const {height, width} = useWindowDimensions()
const insets = useSafeAreaInsets() 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 = videoAspectRatio <= 9 / 16
const {status} = useEvent(player, 'statusChange', {status: player.status})
return ( return (
<View <View
style={{ style={{
@@ -289,7 +331,71 @@ function VibeItem({
width, width,
}}> }}>
<SafeAreaView edges={['left', 'right', 'bottom']} style={[a.flex_1]}> <SafeAreaView edges={['left', 'right', 'bottom']} style={[a.flex_1]}>
{active && ( {player ? (
<VibeItemInner player={player} embed={embed} active={active} />
) : (
embed.thumbnail && (
<Image
accessibilityIgnoresInvertColors
source={{uri: embed.thumbnail}}
style={[
a.flex_1,
a.absolute,
{
top: 0,
left: insets.left,
right: insets.right,
bottom: insets.bottom,
},
]}
contentFit="contain"
/>
)
)}
{postShadow !== POST_TOMBSTONE ? (
player && <VibeOverlay player={player} post={postShadow} />
) : (
<View
style={[
a.absolute,
a.inset_0,
a.z_20,
a.align_center,
a.justify_center,
{backgroundColor: 'rgba(0, 0, 0, 0.8)'},
]}>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.leading_tight]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
)}
</SafeAreaView>
</View>
)
}
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 && (
<VideoView <VideoView
style={[a.flex_1]} style={[a.flex_1]}
player={player} player={player}
@@ -306,7 +412,7 @@ function VibeItem({
a.flex_1, a.flex_1,
a.absolute, a.absolute,
{ {
zIndex: status === 'loading' ? 1 : -1, zIndex: status === 'loading' && isAndroid ? 1 : -1,
top: 0, top: 0,
left: insets.left, left: insets.left,
right: insets.right, right: insets.right,
@@ -316,26 +422,24 @@ function VibeItem({
contentFit={isCloseEnough ? 'cover' : 'contain'} contentFit={isCloseEnough ? 'cover' : 'contain'}
/> />
)} )}
<VibeOverlay player={player} post={post} status={status} /> </>
</SafeAreaView>
</View>
) )
} }
function VibeOverlay({ function VibeOverlay({
player, player,
post, post,
status,
}: { }: {
player: VideoPlayer player: VideoPlayer
post: AppBskyFeedDefs.PostView post: Shadow<AppBskyFeedDefs.PostView>
status: VideoPlayerStatus
}) { }) {
const postShadow = usePostShadow(post)
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const t = useTheme() const t = useTheme()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const {status} = useEvent(player, 'statusChange', {status: player.status})
const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [queueLike] = usePostLikeMutationQueue(post, 'Vibe')
const pushToProfile = useNonReactiveCallback(() => { const pushToProfile = useNonReactiveCallback(() => {
navigation.navigate('Profile', {name: post.author.did}) navigation.navigate('Profile', {name: post.author.did})
@@ -349,6 +453,16 @@ function VibeOverlay({
} }
} }
const onPress = () => {
if (doubleTapRef.current) {
clearTimeout(doubleTapRef.current)
doubleTapRef.current = null
queueLike()
} else {
doubleTapRef.current = setTimeout(togglePlayPause, 300)
}
}
const gesture = useMemo(() => { const gesture = useMemo(() => {
const dragLeftGesture = Gesture.Pan() const dragLeftGesture = Gesture.Pan()
.activeOffsetX([0, 10]) .activeOffsetX([0, 10])
@@ -375,10 +489,11 @@ function VibeOverlay({
return ( return (
<> <>
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
<View style={[a.absolute, a.inset_0, {bottom: insets.bottom}]}> <View style={[a.absolute, a.inset_0, a.z_20, {bottom: insets.bottom}]}>
<Button <Button
label="Toggle play/pause" label="Toggle play/pause"
onPress={togglePlayPause} accessibilityHint="Double tap to like"
onPress={onPress}
style={[a.flex_1]}> style={[a.flex_1]}>
<View /> <View />
</Button> </Button>
@@ -412,10 +527,10 @@ function VibeOverlay({
authorHandle={post.author.handle} authorHandle={post.author.handle}
/> />
)} )}
{postShadow !== POST_TOMBSTONE && record && ( {record && (
<PostCtrls <PostCtrls
richText={richText} richText={richText}
post={postShadow} post={post}
record={record} record={record}
logContext="FeedItem" logContext="FeedItem"
onPressReply={() => onPressReply={() =>