[Video] Visibility detection view (#4741)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
zio/stable
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

@ -104,4 +104,7 @@ jest.mock('expo-modules-core', () => ({
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
return () => null
}),
}))

View File

@ -0,0 +1,23 @@
package expo.modules.blueskyswissarmy.visibilityview
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyVisibilityViewModule : Module() {
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyVisibilityView")
AsyncFunction("updateActiveViewAsync") {
VisibilityViewManager.updateActiveView()
}
View(VisibilityView::class) {
Events(arrayOf("onChangeStatus"))
Prop("enabled") { view: VisibilityView, prop: Boolean ->
view.isViewEnabled = prop
}
}
}
}

View File

@ -0,0 +1,63 @@
package expo.modules.blueskyswissarmy.visibilityview
import android.content.Context
import android.graphics.Rect
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class VisibilityView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext) {
var isViewEnabled: Boolean = false
private val onChangeStatus by EventDispatcher()
private var isCurrentlyActive = false
override fun onAttachedToWindow() {
super.onAttachedToWindow()
VisibilityViewManager.addView(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
VisibilityViewManager.removeView(this)
}
fun setIsCurrentlyActive(isActive: Boolean) {
if (isCurrentlyActive == isActive) {
return
}
this.isCurrentlyActive = isActive
this.onChangeStatus(
mapOf(
"isActive" to isActive,
),
)
}
fun getPositionOnScreen(): Rect? {
if (!this.isShown) {
return null
}
val screenPosition = intArrayOf(0, 0)
this.getLocationInWindow(screenPosition)
return Rect(
screenPosition[0],
screenPosition[1],
screenPosition[0] + this.width,
screenPosition[1] + this.height,
)
}
fun isViewableEnough(): Boolean {
val positionOnScreen = this.getPositionOnScreen() ?: return false
val visibleArea = positionOnScreen.width() * positionOnScreen.height()
val totalArea = this.width * this.height
return visibleArea >= 0.5 * totalArea
}
}

View File

@ -0,0 +1,82 @@
package expo.modules.blueskyswissarmy.visibilityview
import android.graphics.Rect
class VisibilityViewManager {
companion object {
private val views = HashMap<Int, VisibilityView>()
private var currentlyActiveView: VisibilityView? = null
private var prevCount = 0
fun addView(view: VisibilityView) {
this.views[view.id] = view
if (this.prevCount == 0) {
this.updateActiveView()
}
this.prevCount = this.views.count()
}
fun removeView(view: VisibilityView) {
this.views.remove(view.id)
this.prevCount = this.views.count()
}
fun updateActiveView() {
var activeView: VisibilityView? = null
val count = this.views.count()
if (count == 1) {
val view = this.views.values.first()
if (view.isViewableEnough()) {
activeView = view
}
} else if (count > 1) {
val views = this.views.values
var mostVisibleView: VisibilityView? = null
var mostVisiblePosition: Rect? = null
views.forEach { view ->
if (!view.isViewableEnough()) {
return
}
val position = view.getPositionOnScreen() ?: return@forEach
val topY = position.centerY() - (position.height() / 2)
if (topY >= 150) {
if (mostVisiblePosition == null) {
mostVisiblePosition = position
}
if (position.centerY() <= mostVisiblePosition!!.centerY()) {
mostVisibleView = view
mostVisiblePosition = position
}
}
}
activeView = mostVisibleView
}
if (activeView == this.currentlyActiveView) {
return
}
this.clearActiveView()
if (activeView != null) {
this.setActiveView(activeView)
}
}
private fun clearActiveView() {
this.currentlyActiveView?.setIsCurrentlyActive(false)
this.currentlyActiveView = null
}
private fun setActiveView(view: VisibilityView) {
view.setIsCurrentlyActive(true)
this.currentlyActiveView = view
}
}
}

View File

@ -1,12 +1,18 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"]
"modules": [
"ExpoBlueskySharedPrefsModule",
"ExpoBlueskyReferrerModule",
"ExpoBlueskyVisibilityViewModule",
"ExpoPlatformInfoModule"
]
},
"android": {
"modules": [
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
]
}

View File

@ -1,5 +1,6 @@
import * as PlatformInfo from './src/PlatformInfo'
import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
import VisibilityView from './src/VisibilityView'
export {PlatformInfo, Referrer, SharedPrefs}
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}

View File

@ -0,0 +1,21 @@
import ExpoModulesCore
public class ExpoBlueskyVisibilityViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyVisibilityView")
AsyncFunction("updateActiveViewAsync") {
VisibilityViewManager.shared.updateActiveView()
}
View(VisibilityView.self) {
Events([
"onChangeStatus"
])
Prop("enabled") { (view: VisibilityView, prop: Bool) in
view.enabled = prop
}
}
}
}

View File

@ -0,0 +1,86 @@
import Foundation
class VisibilityViewManager {
static let shared = VisibilityViewManager()
private let views = NSHashTable<VisibilityView>(options: .weakMemory)
private var currentlyActiveView: VisibilityView?
private var screenHeight: CGFloat = UIScreen.main.bounds.height
private var prevCount = 0
func addView(_ view: VisibilityView) {
self.views.add(view)
if self.prevCount == 0 {
self.updateActiveView()
}
self.prevCount = self.views.count
}
func removeView(_ view: VisibilityView) {
self.views.remove(view)
self.prevCount = self.views.count
}
func updateActiveView() {
DispatchQueue.main.async {
var activeView: VisibilityView?
if self.views.count == 1 {
let view = self.views.allObjects[0]
if view.isViewableEnough() {
activeView = view
}
} else if self.views.count > 1 {
let views = self.views.allObjects
var mostVisibleView: VisibilityView?
var mostVisiblePosition: CGRect?
views.forEach { view in
if !view.isViewableEnough() {
return
}
guard let position = view.getPositionOnScreen() else {
return
}
if position.minY >= 150 {
if mostVisiblePosition == nil {
mostVisiblePosition = position
}
if let unwrapped = mostVisiblePosition,
position.minY <= unwrapped.minY {
mostVisibleView = view
mostVisiblePosition = position
}
}
}
activeView = mostVisibleView
}
if activeView == self.currentlyActiveView {
return
}
self.clearActiveView()
if let view = activeView {
self.setActiveView(view)
}
}
}
private func clearActiveView() {
if let currentlyActiveView = self.currentlyActiveView {
currentlyActiveView.setIsCurrentlyActive(isActive: false)
self.currentlyActiveView = nil
}
}
private func setActiveView(_ view: VisibilityView) {
view.setIsCurrentlyActive(isActive: true)
self.currentlyActiveView = view
}
}

View File

@ -0,0 +1,69 @@
import ExpoModulesCore
class VisibilityView: ExpoView {
var enabled = false {
didSet {
if enabled {
VisibilityViewManager.shared.removeView(self)
}
}
}
private let onChangeStatus = EventDispatcher()
private var isCurrentlyActiveView = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
}
public override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
if !self.enabled {
return
}
if newWindow == nil {
VisibilityViewManager.shared.removeView(self)
} else {
VisibilityViewManager.shared.addView(self)
}
}
func setIsCurrentlyActive(isActive: Bool) {
if isCurrentlyActiveView == isActive {
return
}
self.isCurrentlyActiveView = isActive
self.onChangeStatus([
"isActive": isActive
])
}
}
// 🚨 DANGER 🚨
// These functions need to be called from the main thread. Xcode will warn you if you call one of them
// off the main thread, so pay attention!
extension UIView {
func getPositionOnScreen() -> CGRect? {
if let window = self.window {
return self.convert(self.bounds, to: window)
}
return nil
}
func isViewableEnough() -> Bool {
guard let window = self.window else {
return false
}
let viewFrameOnScreen = self.convert(self.bounds, to: window)
let screenBounds = window.bounds
let intersection = viewFrameOnScreen.intersection(screenBounds)
let viewHeight = viewFrameOnScreen.height
let intersectionHeight = intersection.height
return intersectionHeight >= 0.5 * viewHeight
}
}

View File

@ -0,0 +1,39 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: Boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
export async function updateActiveViewAsync() {
await NativeModule.updateActiveViewAsync()
}
export default function VisibilityView({
children,
onChangeStatus: onChangeStatusOuter,
enabled,
}: VisibilityViewProps) {
const onChangeStatus = React.useCallback(
(e: {nativeEvent: {isActive: boolean}}) => {
onChangeStatusOuter(e.nativeEvent.isActive)
},
[onChangeStatusOuter],
)
return (
<NativeView
onChangeStatus={onChangeStatus}
enabled={enabled}
style={{flex: 1}}>
{children}
</NativeView>
)
}

View File

@ -0,0 +1,10 @@
import {NotImplementedError} from '../NotImplemented'
import {VisibilityViewProps} from './types'
export async function updateActiveViewAsync() {
throw new NotImplementedError()
}
export default function VisibilityView({children}: VisibilityViewProps) {
return children
}

View File

@ -0,0 +1,6 @@
import React from 'react'
export interface VisibilityViewProps {
children: React.ReactNode
onChangeStatus: (isActive: boolean) => void
enabled: boolean
}

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'