release video players when leaving screen
This commit is contained in:
@@ -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={() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user