[Video] Visibility detection view (#4741)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
This commit is contained in:
Hailey 2024-08-07 14:45:06 -07:00 committed by GitHub
parent fff2c079c2
commit 1b02f81cb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 564 additions and 178 deletions

View file

@ -13,5 +13,6 @@ export type Gate =
| 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'video_debug'
| 'videos'
| 'small_avi_thumb'

View file

@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
outsideHeaderOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn

View file

@ -194,6 +194,7 @@ export function Feed({
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
removeClippedSubviews={true}
/>
</View>
)

View file

@ -180,6 +180,7 @@ let Feed = ({
ListHeaderComponent?: () => JSX.Element
extraData?: any
savedFeedConfig?: AppBskyActorDefs.SavedFeed
outsideHeaderOffset?: number
}): React.ReactNode => {
const theme = useTheme()
const {track} = useAnalytics()

View file

@ -356,7 +356,7 @@ let FeedItemInner = ({
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
/>
{__DEV__ && gate('videos') && (
{gate('video_debug') && (
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
)}
<PostCtrls

View file

@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {usePalette} from '#/lib/hooks/usePalette'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL
@ -47,6 +49,7 @@ function ListImpl<ItemT>(
) {
const isScrolledDown = useSharedValue(false)
const pal = usePalette('default')
const dedupe = useDedupe()
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@ -77,6 +80,8 @@ function ListImpl<ItemT>(
runOnJS(handleScrolledDownChange)(didScrollDown)
}
}
runOnJS(dedupe)(updateActiveViewAsync)
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.

View file

@ -1,21 +1,20 @@
import React, {useCallback} from 'react'
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {useActiveVideoView} from './ActiveVideoContext'
import {VideoEmbedInner} from './VideoEmbedInner'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const {active, setActive} = useActiveVideoView({source})
const {_} = useLingui()
const onPress = useCallback(() => setActive(), [setActive])
return (
<View
style={[
@ -26,25 +25,27 @@ export function VideoEmbed({source}: {source: string}) {
t.atoms.bg_contrast_25,
a.my_xs,
]}>
{active ? (
<VideoEmbedInner
source={source}
// web only
active={active}
setActive={setActive}
onScreen={true}
/>
) : (
<Button
style={[a.flex_1, t.atoms.bg_contrast_25]}
onPress={onPress}
label={_(msg`Play video`)}
variant="ghost"
color="secondary"
size="large">
<ButtonIcon icon={PlayIcon} />
</Button>
)}
<VisibilityView
enabled={true}
onChangeStatus={isActive => {
if (isActive) {
setActive()
}
}}>
{active ? (
<VideoEmbedInnerNative />
) : (
<Button
style={[a.flex_1, t.atoms.bg_contrast_25]}
onPress={setActive}
label={_(msg`Play video`)}
variant="ghost"
color="secondary"
size="large">
<ButtonIcon icon={PlayIcon} />
</Button>
)}
</VisibilityView>
</View>
)
}

View file

@ -3,13 +3,15 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoView} from './ActiveVideoContext'
import {VideoEmbedInner} from './VideoEmbedInner'
import {HLSUnsupportedError} from './VideoEmbedInner.web'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
@ -60,7 +62,7 @@ export function VideoEmbed({source}: {source: string}) {
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<VideoEmbedInner
<VideoEmbedInnerWeb
source={source}
active={active}
setActive={setActive}

View file

@ -1,143 +0,0 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
import Animated, {
measure,
runOnJS,
useAnimatedRef,
useFrameCallback,
useSharedValue,
} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {useVideoPlayer} from './VideoPlayerContext'
export function VideoEmbedInner({}: {
source: string
active: boolean
setActive: () => void
onScreen: boolean
}) {
const player = useVideoPlayer()
const aref = useAnimatedRef<Animated.View>()
const {height: windowHeight} = useWindowDimensions()
const hasLeftView = useSharedValue(false)
const ref = useRef<VideoView>(null)
const onEnterView = useCallback(() => {
if (player.status === 'readyToPlay') {
player.play()
}
}, [player])
const onLeaveView = useCallback(() => {
player.pause()
}, [player])
const enterFullscreen = useCallback(() => {
if (ref.current) {
ref.current.enterFullscreen()
}
}, [])
useFrameCallback(() => {
const measurement = measure(aref)
if (measurement) {
if (hasLeftView.value) {
// Check if the video is in view
if (
measurement.pageY >= 0 &&
measurement.pageY + measurement.height <= windowHeight
) {
runOnJS(onEnterView)()
hasLeftView.value = false
}
} else {
// Check if the video is out of view
if (
measurement.pageY + measurement.height < 0 ||
measurement.pageY > windowHeight
) {
runOnJS(onLeaveView)()
hasLeftView.value = true
}
}
}
})
return (
<Animated.View
style={[a.flex_1, a.relative]}
ref={aref}
collapsable={false}>
<VideoView
ref={ref}
player={player}
style={a.flex_1}
nativeControls={true}
/>
<VideoControls player={player} enterFullscreen={enterFullscreen} />
</Animated.View>
)
}
function VideoControls({
player,
enterFullscreen,
}: {
player: VideoPlayer
enterFullscreen: () => void
}) {
const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Math.floor(player.duration - player.currentTime))
// how often should we update the time?
// 1000 gets out of sync with the video time
}, 250)
return () => {
clearInterval(interval)
}
}, [player])
const minutes = Math.floor(currentTime / 60)
const seconds = String(currentTime % 60).padStart(2, '0')
return (
<View style={[a.absolute, a.inset_0]}>
<View style={styles.timeContainer} pointerEvents="none">
<Text style={styles.timeElapsed}>
{minutes}:{seconds}
</Text>
</View>
<Pressable
onPress={enterFullscreen}
style={a.flex_1}
accessibilityLabel="Video"
accessibilityHint="Tap to enter full screen"
accessibilityRole="button"
/>
</View>
)
}
const styles = StyleSheet.create({
timeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
left: 5,
bottom: 5,
},
timeElapsed: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
},
})

View file

@ -0,0 +1,96 @@
import React, {useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {VideoPlayer, VideoView} from 'expo-video'
import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext'
import {android, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
export function VideoEmbedInnerNative() {
const player = useVideoPlayer()
const ref = useRef<VideoView>(null)
return (
<View style={[a.flex_1, a.relative]} collapsable={false}>
<VideoView
ref={ref}
player={player}
style={a.flex_1}
nativeControls={true}
/>
<Controls
player={player}
enterFullscreen={() => ref.current?.enterFullscreen()}
/>
</View>
)
}
function Controls({
player,
enterFullscreen,
}: {
player: VideoPlayer
enterFullscreen: () => void
}) {
const [duration, setDuration] = useState(() => Math.floor(player.duration))
const [currentTime, setCurrentTime] = useState(() =>
Math.floor(player.currentTime),
)
const timeRemaining = duration - currentTime
const minutes = Math.floor(timeRemaining / 60)
const seconds = String(timeRemaining % 60).padStart(2, '0')
useEffect(() => {
const interval = setInterval(() => {
// duration gets reset to 0 on loop
if (player.duration) setDuration(Math.floor(player.duration))
setCurrentTime(Math.floor(player.currentTime))
// how often should we update the time?
// 1000 gets out of sync with the video time
}, 250)
return () => {
clearInterval(interval)
}
}, [player])
if (isNaN(timeRemaining)) {
return null
}
return (
<View style={[a.absolute, a.inset_0]}>
<View
style={[
{
backgroundColor: 'rgba(0, 0, 0, 0.75',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
left: 5,
bottom: 5,
},
]}
pointerEvents="none">
<Text
style={[
{color: 'white', fontSize: 12},
a.font_bold,
android({lineHeight: 1.25}),
]}>
{minutes}:{seconds}
</Text>
</View>
<Pressable
onPress={enterFullscreen}
style={a.flex_1}
accessibilityLabel="Video"
accessibilityHint="Tap to enter full screen"
accessibilityRole="button"
/>
</View>
)
}

View file

@ -0,0 +1,3 @@
export function VideoEmbedInnerNative() {
throw new Error('VideoEmbedInnerNative may not be used on native.')
}

View file

@ -0,0 +1,3 @@
export function VideoEmbedInnerWeb() {
throw new Error('VideoEmbedInnerWeb may not be used on native.')
}

View file

@ -5,17 +5,23 @@ import Hls from 'hls.js'
import {atoms as a} from '#/alf'
import {Controls} from './VideoWebControls'
export function VideoEmbedInner({
export function VideoEmbedInnerWeb({
source,
active,
setActive,
onScreen,
}: {
source: string
active: boolean
setActive: () => void
onScreen: boolean
active?: boolean
setActive?: () => void
onScreen?: boolean
}) {
if (active == null || setActive == null || onScreen == null) {
throw new Error(
'active, setActive, and onScreen are required VideoEmbedInner props on web.',
)
}
const containerRef = useRef<HTMLDivElement>(null)
const ref = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false)

View file

@ -11,12 +11,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type Hls from 'hls.js'
import {isIPhoneWeb} from '#/platform/detection'
import {isIPhoneWeb} from 'platform/detection'
import {
useAutoplayDisabled,
useSetSubtitlesEnabled,
useSubtitlesEnabled,
} from '#/state/preferences'
} from 'state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'