From 1b02f81cb85333462e3a9a42accc05d09aca4f2c Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 7 Aug 2024 14:45:06 -0700 Subject: [PATCH] [Video] Visibility detection view (#4741) Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- jest/jestSetup.js | 3 + .../ExpoBlueskyVisibilityViewModule.kt | 23 +++ .../visibilityview/VisibilityView.kt | 63 ++++++++ .../visibilityview/VisibilityViewManager.kt | 82 ++++++++++ .../expo-module.config.json | 8 +- modules/expo-bluesky-swiss-army/index.ts | 3 +- .../ExpoBlueskyVisibilityViewModule.swift | 21 +++ .../Visibility/VisibilityViewManager.swift | 86 +++++++++++ .../ios/Visibility/VisiblityView.swift | 69 +++++++++ .../src/VisibilityView/index.native.tsx | 39 +++++ .../src/VisibilityView/index.tsx | 10 ++ .../src/VisibilityView/types.ts | 6 + src/lib/statsig/gates.ts | 1 + src/screens/Profile/Sections/Feed.tsx | 1 + src/view/com/notifications/Feed.tsx | 1 + src/view/com/posts/Feed.tsx | 1 + src/view/com/posts/FeedItem.tsx | 2 +- src/view/com/util/List.tsx | 5 + src/view/com/util/post-embeds/VideoEmbed.tsx | 47 +++--- .../com/util/post-embeds/VideoEmbed.web.tsx | 8 +- .../com/util/post-embeds/VideoEmbedInner.tsx | 143 ------------------ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 96 ++++++++++++ .../VideoEmbedInnerNative.web.tsx | 3 + .../VideoEmbedInnerWeb.native.tsx | 3 + .../VideoEmbedInnerWeb.tsx} | 14 +- .../VideoWebControls.tsx | 0 .../VideoWebControls.web.tsx | 4 +- 27 files changed, 564 insertions(+), 178 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt create mode 100644 modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt create mode 100644 modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift create mode 100644 modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift create mode 100644 modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift create mode 100644 modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx create mode 100644 modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts delete mode 100644 src/view/com/util/post-embeds/VideoEmbedInner.tsx create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx rename src/view/com/util/post-embeds/{VideoEmbedInner.web.tsx => VideoEmbedInner/VideoEmbedInnerWeb.tsx} (88%) rename src/view/com/util/post-embeds/{ => VideoEmbedInner}/VideoWebControls.tsx (100%) rename src/view/com/util/post-embeds/{ => VideoEmbedInner}/VideoWebControls.web.tsx (99%) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index ac175900..a68c1dc4 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -104,4 +104,7 @@ jest.mock('expo-modules-core', () => ({ } } }), + requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + return () => null + }), })) diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt new file mode 100644 index 00000000..ddbb05cd --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt @@ -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 + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt new file mode 100644 index 00000000..a55ab80d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt @@ -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 + } +} diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt new file mode 100644 index 00000000..ec1e4981 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt @@ -0,0 +1,82 @@ +package expo.modules.blueskyswissarmy.visibilityview + +import android.graphics.Rect + +class VisibilityViewManager { + companion object { + private val views = HashMap() + 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 + } + } +} diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index adb535e7..4cdc11e9 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -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" ] } diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts index f62596cb..ebd67913 100644 --- a/modules/expo-bluesky-swiss-army/index.ts +++ b/modules/expo-bluesky-swiss-army/index.ts @@ -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} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift new file mode 100644 index 00000000..ec12a84a --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift @@ -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 + } + } + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift new file mode 100644 index 00000000..ae8e1686 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift @@ -0,0 +1,86 @@ +import Foundation + +class VisibilityViewManager { + static let shared = VisibilityViewManager() + + private let views = NSHashTable(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 + } +} diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift new file mode 100644 index 00000000..fd99ee49 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift @@ -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 + } +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx new file mode 100644 index 00000000..9d0e8cf2 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx @@ -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 +}> = 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 ( + + {children} + + ) +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx new file mode 100644 index 00000000..8b4f1928 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx @@ -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 +} diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts new file mode 100644 index 00000000..312acf2d --- /dev/null +++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts @@ -0,0 +1,6 @@ +import React from 'react' +export interface VisibilityViewProps { + children: React.ReactNode + onChangeStatus: (isActive: boolean) => void + enabled: boolean +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 58a60232..4b482b47 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -13,5 +13,6 @@ export type Gate = | 'suggested_feeds_interstitial' | 'suggested_follows_interstitial' | 'ungroup_follow_backs' + | 'video_debug' | 'videos' | 'small_avi_thumb' diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index 201c8f7e..e7ceaab0 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef< headerOffset={headerHeight} renderEndOfFeed={ProfileEndOfFeed} ignoreFilterFor={ignoreFilterFor} + outsideHeaderOffset={headerHeight} /> {(isScrolledDown || hasNew) && ( ) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 46bf4a5f..aa45d3ac 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -180,6 +180,7 @@ let Feed = ({ ListHeaderComponent?: () => JSX.Element extraData?: any savedFeedConfig?: AppBskyActorDefs.SavedFeed + outsideHeaderOffset?: number }): React.ReactNode => { const theme = useTheme() const {track} = useAnalytics() diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index a6e721d4..6660a8d9 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -356,7 +356,7 @@ let FeedItemInner = ({ postAuthor={post.author} onOpenEmbed={onOpenEmbed} /> - {__DEV__ && gate('videos') && ( + {gate('video_debug') && ( )} ( ) { const isScrolledDown = useSharedValue(false) const pal = usePalette('default') + const dedupe = useDedupe() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -77,6 +80,8 @@ function ListImpl( 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. diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 429312d9..887efac1 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -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 ( - {active ? ( - - ) : ( - - )} + { + if (isActive) { + setActive() + } + }}> + {active ? ( + + ) : ( + + )} + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 08932f91..70d88728 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -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}) { - void - onScreen: boolean -}) { - const player = useVideoPlayer() - const aref = useAnimatedRef() - const {height: windowHeight} = useWindowDimensions() - const hasLeftView = useSharedValue(false) - const ref = useRef(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 ( - - - - - ) -} - -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 ( - - - - {minutes}:{seconds} - - - - - ) -} - -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', - }, -}) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 00000000..cc356fb0 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -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(null) + + return ( + + + ref.current?.enterFullscreen()} + /> + + ) +} + +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 ( + + + + {minutes}:{seconds} + + + + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 00000000..59da5be4 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 00000000..8664aae1 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx similarity index 88% rename from src/view/com/util/post-embeds/VideoEmbedInner.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index f5f47db5..c0021d9b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -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(null) const ref = useRef(null) const [focused, setFocused] = useState(false) diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx similarity index 100% rename from src/view/com/util/post-embeds/VideoWebControls.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx similarity index 99% rename from src/view/com/util/post-embeds/VideoWebControls.web.tsx rename to src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx index 2843664b..7caaf3ab 100644 --- a/src/view/com/util/post-embeds/VideoWebControls.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx @@ -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'