[Video] Visibility detection view (#4741)
Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>zio/stable
parent
fff2c079c2
commit
1b02f81cb8
|
@ -104,4 +104,7 @@ jest.mock('expo-modules-core', () => ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||||
|
return () => null
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,18 @@
|
||||||
{
|
{
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"]
|
"modules": [
|
||||||
|
"ExpoBlueskySharedPrefsModule",
|
||||||
|
"ExpoBlueskyReferrerModule",
|
||||||
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
|
"ExpoPlatformInfoModule"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"modules": [
|
"modules": [
|
||||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||||
|
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
||||||
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as PlatformInfo from './src/PlatformInfo'
|
import * as PlatformInfo from './src/PlatformInfo'
|
||||||
import * as Referrer from './src/Referrer'
|
import * as Referrer from './src/Referrer'
|
||||||
import * as SharedPrefs from './src/SharedPrefs'
|
import * as SharedPrefs from './src/SharedPrefs'
|
||||||
|
import VisibilityView from './src/VisibilityView'
|
||||||
|
|
||||||
export {PlatformInfo, Referrer, SharedPrefs}
|
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import React from 'react'
|
||||||
|
export interface VisibilityViewProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
onChangeStatus: (isActive: boolean) => void
|
||||||
|
enabled: boolean
|
||||||
|
}
|
|
@ -13,5 +13,6 @@ export type Gate =
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'suggested_follows_interstitial'
|
| 'suggested_follows_interstitial'
|
||||||
| 'ungroup_follow_backs'
|
| 'ungroup_follow_backs'
|
||||||
|
| 'video_debug'
|
||||||
| 'videos'
|
| 'videos'
|
||||||
| 'small_avi_thumb'
|
| 'small_avi_thumb'
|
||||||
|
|
|
@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
renderEndOfFeed={ProfileEndOfFeed}
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
ignoreFilterFor={ignoreFilterFor}
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
|
outsideHeaderOffset={headerHeight}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|
|
@ -194,6 +194,7 @@ export function Feed({
|
||||||
initialNumToRender={initialNumToRender}
|
initialNumToRender={initialNumToRender}
|
||||||
windowSize={11}
|
windowSize={11}
|
||||||
sideBorders={false}
|
sideBorders={false}
|
||||||
|
removeClippedSubviews={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -180,6 +180,7 @@ let Feed = ({
|
||||||
ListHeaderComponent?: () => JSX.Element
|
ListHeaderComponent?: () => JSX.Element
|
||||||
extraData?: any
|
extraData?: any
|
||||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||||
|
outsideHeaderOffset?: number
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
|
|
|
@ -356,7 +356,7 @@ let FeedItemInner = ({
|
||||||
postAuthor={post.author}
|
postAuthor={post.author}
|
||||||
onOpenEmbed={onOpenEmbed}
|
onOpenEmbed={onOpenEmbed}
|
||||||
/>
|
/>
|
||||||
{__DEV__ && gate('videos') && (
|
{gate('video_debug') && (
|
||||||
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
||||||
)}
|
)}
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
|
|
|
@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||||
|
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||||
import {addStyle} from 'lib/styles'
|
import {addStyle} from 'lib/styles'
|
||||||
|
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||||
import {FlatList_INTERNAL} from './Views'
|
import {FlatList_INTERNAL} from './Views'
|
||||||
|
|
||||||
export type ListMethods = FlatList_INTERNAL
|
export type ListMethods = FlatList_INTERNAL
|
||||||
|
@ -47,6 +49,7 @@ function ListImpl<ItemT>(
|
||||||
) {
|
) {
|
||||||
const isScrolledDown = useSharedValue(false)
|
const isScrolledDown = useSharedValue(false)
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
const dedupe = useDedupe()
|
||||||
|
|
||||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||||
onScrolledDownChange?.(didScrollDown)
|
onScrolledDownChange?.(didScrollDown)
|
||||||
|
@ -77,6 +80,8 @@ function ListImpl<ItemT>(
|
||||||
runOnJS(handleScrolledDownChange)(didScrollDown)
|
runOnJS(handleScrolledDownChange)(didScrollDown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runOnJS(dedupe)(updateActiveViewAsync)
|
||||||
},
|
},
|
||||||
// Note: adding onMomentumBegin here makes simulator scroll
|
// Note: adding onMomentumBegin here makes simulator scroll
|
||||||
// lag on Android. So either don't add it, or figure out why.
|
// lag on Android. So either don't add it, or figure out why.
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
import React, {useCallback} from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {useActiveVideoView} from './ActiveVideoContext'
|
import {useActiveVideoView} from './ActiveVideoContext'
|
||||||
import {VideoEmbedInner} from './VideoEmbedInner'
|
|
||||||
|
|
||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({source}: {source: string}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {active, setActive} = useActiveVideoView({source})
|
const {active, setActive} = useActiveVideoView({source})
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const onPress = useCallback(() => setActive(), [setActive])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -26,25 +25,27 @@ export function VideoEmbed({source}: {source: string}) {
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
{active ? (
|
<VisibilityView
|
||||||
<VideoEmbedInner
|
enabled={true}
|
||||||
source={source}
|
onChangeStatus={isActive => {
|
||||||
// web only
|
if (isActive) {
|
||||||
active={active}
|
setActive()
|
||||||
setActive={setActive}
|
}
|
||||||
onScreen={true}
|
}}>
|
||||||
/>
|
{active ? (
|
||||||
) : (
|
<VideoEmbedInnerNative />
|
||||||
<Button
|
) : (
|
||||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
<Button
|
||||||
onPress={onPress}
|
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||||
label={_(msg`Play video`)}
|
onPress={setActive}
|
||||||
variant="ghost"
|
label={_(msg`Play video`)}
|
||||||
color="secondary"
|
variant="ghost"
|
||||||
size="large">
|
color="secondary"
|
||||||
<ButtonIcon icon={PlayIcon} />
|
size="large">
|
||||||
</Button>
|
<ButtonIcon icon={PlayIcon} />
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</VisibilityView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,13 +3,15 @@ import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
HLSUnsupportedError,
|
||||||
|
VideoEmbedInnerWeb,
|
||||||
|
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {ErrorBoundary} from '../ErrorBoundary'
|
import {ErrorBoundary} from '../ErrorBoundary'
|
||||||
import {useActiveVideoView} from './ActiveVideoContext'
|
import {useActiveVideoView} from './ActiveVideoContext'
|
||||||
import {VideoEmbedInner} from './VideoEmbedInner'
|
|
||||||
import {HLSUnsupportedError} from './VideoEmbedInner.web'
|
|
||||||
|
|
||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({source}: {source: string}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
@ -60,7 +62,7 @@ export function VideoEmbed({source}: {source: string}) {
|
||||||
<ViewportObserver
|
<ViewportObserver
|
||||||
sendPosition={sendPosition}
|
sendPosition={sendPosition}
|
||||||
isAnyViewActive={currentActiveView !== null}>
|
isAnyViewActive={currentActiveView !== null}>
|
||||||
<VideoEmbedInner
|
<VideoEmbedInnerWeb
|
||||||
source={source}
|
source={source}
|
||||||
active={active}
|
active={active}
|
||||||
setActive={setActive}
|
setActive={setActive}
|
||||||
|
|
|
@ -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',
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function VideoEmbedInnerNative() {
|
||||||
|
throw new Error('VideoEmbedInnerNative may not be used on native.')
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function VideoEmbedInnerWeb() {
|
||||||
|
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
||||||
|
}
|
|
@ -5,17 +5,23 @@ import Hls from 'hls.js'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Controls} from './VideoWebControls'
|
import {Controls} from './VideoWebControls'
|
||||||
|
|
||||||
export function VideoEmbedInner({
|
export function VideoEmbedInnerWeb({
|
||||||
source,
|
source,
|
||||||
active,
|
active,
|
||||||
setActive,
|
setActive,
|
||||||
onScreen,
|
onScreen,
|
||||||
}: {
|
}: {
|
||||||
source: string
|
source: string
|
||||||
active: boolean
|
active?: boolean
|
||||||
setActive: () => void
|
setActive?: () => void
|
||||||
onScreen: boolean
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
|
@ -11,12 +11,12 @@ import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import type Hls from 'hls.js'
|
import type Hls from 'hls.js'
|
||||||
|
|
||||||
import {isIPhoneWeb} from '#/platform/detection'
|
import {isIPhoneWeb} from 'platform/detection'
|
||||||
import {
|
import {
|
||||||
useAutoplayDisabled,
|
useAutoplayDisabled,
|
||||||
useSetSubtitlesEnabled,
|
useSetSubtitlesEnabled,
|
||||||
useSubtitlesEnabled,
|
useSubtitlesEnabled,
|
||||||
} from '#/state/preferences'
|
} from 'state/preferences'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
Loading…
Reference in New Issue