Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Video] Visibility detection view (#4741)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

authored by

Hailey
Samuel Newman
and committed by
GitHub
1b02f81c fff2c079

+564 -178
+3
jest/jestSetup.js
··· 104 104 } 105 105 } 106 106 }), 107 + requireNativeViewManager: jest.fn().mockImplementation(moduleName => { 108 + return () => null 109 + }), 107 110 }))
+23
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt
··· 1 + package expo.modules.blueskyswissarmy.visibilityview 2 + 3 + import expo.modules.kotlin.modules.Module 4 + import expo.modules.kotlin.modules.ModuleDefinition 5 + 6 + class ExpoBlueskyVisibilityViewModule : Module() { 7 + override fun definition() = 8 + ModuleDefinition { 9 + Name("ExpoBlueskyVisibilityView") 10 + 11 + AsyncFunction("updateActiveViewAsync") { 12 + VisibilityViewManager.updateActiveView() 13 + } 14 + 15 + View(VisibilityView::class) { 16 + Events(arrayOf("onChangeStatus")) 17 + 18 + Prop("enabled") { view: VisibilityView, prop: Boolean -> 19 + view.isViewEnabled = prop 20 + } 21 + } 22 + } 23 + }
+63
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt
··· 1 + package expo.modules.blueskyswissarmy.visibilityview 2 + 3 + import android.content.Context 4 + import android.graphics.Rect 5 + import expo.modules.kotlin.AppContext 6 + import expo.modules.kotlin.viewevent.EventDispatcher 7 + import expo.modules.kotlin.views.ExpoView 8 + 9 + class VisibilityView( 10 + context: Context, 11 + appContext: AppContext, 12 + ) : ExpoView(context, appContext) { 13 + var isViewEnabled: Boolean = false 14 + 15 + private val onChangeStatus by EventDispatcher() 16 + 17 + private var isCurrentlyActive = false 18 + 19 + override fun onAttachedToWindow() { 20 + super.onAttachedToWindow() 21 + VisibilityViewManager.addView(this) 22 + } 23 + 24 + override fun onDetachedFromWindow() { 25 + super.onDetachedFromWindow() 26 + VisibilityViewManager.removeView(this) 27 + } 28 + 29 + fun setIsCurrentlyActive(isActive: Boolean) { 30 + if (isCurrentlyActive == isActive) { 31 + return 32 + } 33 + 34 + this.isCurrentlyActive = isActive 35 + this.onChangeStatus( 36 + mapOf( 37 + "isActive" to isActive, 38 + ), 39 + ) 40 + } 41 + 42 + fun getPositionOnScreen(): Rect? { 43 + if (!this.isShown) { 44 + return null 45 + } 46 + 47 + val screenPosition = intArrayOf(0, 0) 48 + this.getLocationInWindow(screenPosition) 49 + return Rect( 50 + screenPosition[0], 51 + screenPosition[1], 52 + screenPosition[0] + this.width, 53 + screenPosition[1] + this.height, 54 + ) 55 + } 56 + 57 + fun isViewableEnough(): Boolean { 58 + val positionOnScreen = this.getPositionOnScreen() ?: return false 59 + val visibleArea = positionOnScreen.width() * positionOnScreen.height() 60 + val totalArea = this.width * this.height 61 + return visibleArea >= 0.5 * totalArea 62 + } 63 + }
+82
modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt
··· 1 + package expo.modules.blueskyswissarmy.visibilityview 2 + 3 + import android.graphics.Rect 4 + 5 + class VisibilityViewManager { 6 + companion object { 7 + private val views = HashMap<Int, VisibilityView>() 8 + private var currentlyActiveView: VisibilityView? = null 9 + private var prevCount = 0 10 + 11 + fun addView(view: VisibilityView) { 12 + this.views[view.id] = view 13 + 14 + if (this.prevCount == 0) { 15 + this.updateActiveView() 16 + } 17 + this.prevCount = this.views.count() 18 + } 19 + 20 + fun removeView(view: VisibilityView) { 21 + this.views.remove(view.id) 22 + this.prevCount = this.views.count() 23 + } 24 + 25 + fun updateActiveView() { 26 + var activeView: VisibilityView? = null 27 + val count = this.views.count() 28 + 29 + if (count == 1) { 30 + val view = this.views.values.first() 31 + if (view.isViewableEnough()) { 32 + activeView = view 33 + } 34 + } else if (count > 1) { 35 + val views = this.views.values 36 + var mostVisibleView: VisibilityView? = null 37 + var mostVisiblePosition: Rect? = null 38 + 39 + views.forEach { view -> 40 + if (!view.isViewableEnough()) { 41 + return 42 + } 43 + 44 + val position = view.getPositionOnScreen() ?: return@forEach 45 + val topY = position.centerY() - (position.height() / 2) 46 + 47 + if (topY >= 150) { 48 + if (mostVisiblePosition == null) { 49 + mostVisiblePosition = position 50 + } 51 + 52 + if (position.centerY() <= mostVisiblePosition!!.centerY()) { 53 + mostVisibleView = view 54 + mostVisiblePosition = position 55 + } 56 + } 57 + } 58 + 59 + activeView = mostVisibleView 60 + } 61 + 62 + if (activeView == this.currentlyActiveView) { 63 + return 64 + } 65 + 66 + this.clearActiveView() 67 + if (activeView != null) { 68 + this.setActiveView(activeView) 69 + } 70 + } 71 + 72 + private fun clearActiveView() { 73 + this.currentlyActiveView?.setIsCurrentlyActive(false) 74 + this.currentlyActiveView = null 75 + } 76 + 77 + private fun setActiveView(view: VisibilityView) { 78 + view.setIsCurrentlyActive(true) 79 + this.currentlyActiveView = view 80 + } 81 + } 82 + }
+7 -1
modules/expo-bluesky-swiss-army/expo-module.config.json
··· 1 1 { 2 2 "platforms": ["ios", "tvos", "android", "web"], 3 3 "ios": { 4 - "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoPlatformInfoModule"] 4 + "modules": [ 5 + "ExpoBlueskySharedPrefsModule", 6 + "ExpoBlueskyReferrerModule", 7 + "ExpoBlueskyVisibilityViewModule", 8 + "ExpoPlatformInfoModule" 9 + ] 5 10 }, 6 11 "android": { 7 12 "modules": [ 8 13 "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule", 9 14 "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule", 15 + "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule", 10 16 "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule" 11 17 ] 12 18 }
+2 -1
modules/expo-bluesky-swiss-army/index.ts
··· 1 1 import * as PlatformInfo from './src/PlatformInfo' 2 2 import * as Referrer from './src/Referrer' 3 3 import * as SharedPrefs from './src/SharedPrefs' 4 + import VisibilityView from './src/VisibilityView' 4 5 5 - export {PlatformInfo, Referrer, SharedPrefs} 6 + export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
+21
modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift
··· 1 + import ExpoModulesCore 2 + 3 + public class ExpoBlueskyVisibilityViewModule: Module { 4 + public func definition() -> ModuleDefinition { 5 + Name("ExpoBlueskyVisibilityView") 6 + 7 + AsyncFunction("updateActiveViewAsync") { 8 + VisibilityViewManager.shared.updateActiveView() 9 + } 10 + 11 + View(VisibilityView.self) { 12 + Events([ 13 + "onChangeStatus" 14 + ]) 15 + 16 + Prop("enabled") { (view: VisibilityView, prop: Bool) in 17 + view.enabled = prop 18 + } 19 + } 20 + } 21 + }
+86
modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift
··· 1 + import Foundation 2 + 3 + class VisibilityViewManager { 4 + static let shared = VisibilityViewManager() 5 + 6 + private let views = NSHashTable<VisibilityView>(options: .weakMemory) 7 + private var currentlyActiveView: VisibilityView? 8 + private var screenHeight: CGFloat = UIScreen.main.bounds.height 9 + private var prevCount = 0 10 + 11 + func addView(_ view: VisibilityView) { 12 + self.views.add(view) 13 + 14 + if self.prevCount == 0 { 15 + self.updateActiveView() 16 + } 17 + self.prevCount = self.views.count 18 + } 19 + 20 + func removeView(_ view: VisibilityView) { 21 + self.views.remove(view) 22 + self.prevCount = self.views.count 23 + } 24 + 25 + func updateActiveView() { 26 + DispatchQueue.main.async { 27 + var activeView: VisibilityView? 28 + 29 + if self.views.count == 1 { 30 + let view = self.views.allObjects[0] 31 + if view.isViewableEnough() { 32 + activeView = view 33 + } 34 + } else if self.views.count > 1 { 35 + let views = self.views.allObjects 36 + var mostVisibleView: VisibilityView? 37 + var mostVisiblePosition: CGRect? 38 + 39 + views.forEach { view in 40 + if !view.isViewableEnough() { 41 + return 42 + } 43 + 44 + guard let position = view.getPositionOnScreen() else { 45 + return 46 + } 47 + 48 + if position.minY >= 150 { 49 + if mostVisiblePosition == nil { 50 + mostVisiblePosition = position 51 + } 52 + 53 + if let unwrapped = mostVisiblePosition, 54 + position.minY <= unwrapped.minY { 55 + mostVisibleView = view 56 + mostVisiblePosition = position 57 + } 58 + } 59 + } 60 + 61 + activeView = mostVisibleView 62 + } 63 + 64 + if activeView == self.currentlyActiveView { 65 + return 66 + } 67 + 68 + self.clearActiveView() 69 + if let view = activeView { 70 + self.setActiveView(view) 71 + } 72 + } 73 + } 74 + 75 + private func clearActiveView() { 76 + if let currentlyActiveView = self.currentlyActiveView { 77 + currentlyActiveView.setIsCurrentlyActive(isActive: false) 78 + self.currentlyActiveView = nil 79 + } 80 + } 81 + 82 + private func setActiveView(_ view: VisibilityView) { 83 + view.setIsCurrentlyActive(isActive: true) 84 + self.currentlyActiveView = view 85 + } 86 + }
+69
modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift
··· 1 + import ExpoModulesCore 2 + 3 + class VisibilityView: ExpoView { 4 + var enabled = false { 5 + didSet { 6 + if enabled { 7 + VisibilityViewManager.shared.removeView(self) 8 + } 9 + } 10 + } 11 + 12 + private let onChangeStatus = EventDispatcher() 13 + private var isCurrentlyActiveView = false 14 + 15 + required init(appContext: AppContext? = nil) { 16 + super.init(appContext: appContext) 17 + } 18 + 19 + public override func willMove(toWindow newWindow: UIWindow?) { 20 + super.willMove(toWindow: newWindow) 21 + 22 + if !self.enabled { 23 + return 24 + } 25 + 26 + if newWindow == nil { 27 + VisibilityViewManager.shared.removeView(self) 28 + } else { 29 + VisibilityViewManager.shared.addView(self) 30 + } 31 + } 32 + 33 + func setIsCurrentlyActive(isActive: Bool) { 34 + if isCurrentlyActiveView == isActive { 35 + return 36 + } 37 + self.isCurrentlyActiveView = isActive 38 + self.onChangeStatus([ 39 + "isActive": isActive 40 + ]) 41 + } 42 + } 43 + 44 + // 🚨 DANGER 🚨 45 + // These functions need to be called from the main thread. Xcode will warn you if you call one of them 46 + // off the main thread, so pay attention! 47 + extension UIView { 48 + func getPositionOnScreen() -> CGRect? { 49 + if let window = self.window { 50 + return self.convert(self.bounds, to: window) 51 + } 52 + return nil 53 + } 54 + 55 + func isViewableEnough() -> Bool { 56 + guard let window = self.window else { 57 + return false 58 + } 59 + 60 + let viewFrameOnScreen = self.convert(self.bounds, to: window) 61 + let screenBounds = window.bounds 62 + let intersection = viewFrameOnScreen.intersection(screenBounds) 63 + 64 + let viewHeight = viewFrameOnScreen.height 65 + let intersectionHeight = intersection.height 66 + 67 + return intersectionHeight >= 0.5 * viewHeight 68 + } 69 + }
+39
modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 3 + import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' 4 + 5 + import {VisibilityViewProps} from './types' 6 + const NativeView: React.ComponentType<{ 7 + onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void 8 + children: React.ReactNode 9 + enabled: Boolean 10 + style: StyleProp<ViewStyle> 11 + }> = requireNativeViewManager('ExpoBlueskyVisibilityView') 12 + 13 + const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView') 14 + 15 + export async function updateActiveViewAsync() { 16 + await NativeModule.updateActiveViewAsync() 17 + } 18 + 19 + export default function VisibilityView({ 20 + children, 21 + onChangeStatus: onChangeStatusOuter, 22 + enabled, 23 + }: VisibilityViewProps) { 24 + const onChangeStatus = React.useCallback( 25 + (e: {nativeEvent: {isActive: boolean}}) => { 26 + onChangeStatusOuter(e.nativeEvent.isActive) 27 + }, 28 + [onChangeStatusOuter], 29 + ) 30 + 31 + return ( 32 + <NativeView 33 + onChangeStatus={onChangeStatus} 34 + enabled={enabled} 35 + style={{flex: 1}}> 36 + {children} 37 + </NativeView> 38 + ) 39 + }
+10
modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx
··· 1 + import {NotImplementedError} from '../NotImplemented' 2 + import {VisibilityViewProps} from './types' 3 + 4 + export async function updateActiveViewAsync() { 5 + throw new NotImplementedError() 6 + } 7 + 8 + export default function VisibilityView({children}: VisibilityViewProps) { 9 + return children 10 + }
+6
modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts
··· 1 + import React from 'react' 2 + export interface VisibilityViewProps { 3 + children: React.ReactNode 4 + onChangeStatus: (isActive: boolean) => void 5 + enabled: boolean 6 + }
+1
src/lib/statsig/gates.ts
··· 13 13 | 'suggested_feeds_interstitial' 14 14 | 'suggested_follows_interstitial' 15 15 | 'ungroup_follow_backs' 16 + | 'video_debug' 16 17 | 'videos' 17 18 | 'small_avi_thumb'
+1
src/screens/Profile/Sections/Feed.tsx
··· 79 79 headerOffset={headerHeight} 80 80 renderEndOfFeed={ProfileEndOfFeed} 81 81 ignoreFilterFor={ignoreFilterFor} 82 + outsideHeaderOffset={headerHeight} 82 83 /> 83 84 {(isScrolledDown || hasNew) && ( 84 85 <LoadLatestBtn
+1
src/view/com/notifications/Feed.tsx
··· 194 194 initialNumToRender={initialNumToRender} 195 195 windowSize={11} 196 196 sideBorders={false} 197 + removeClippedSubviews={true} 197 198 /> 198 199 </View> 199 200 )
+1
src/view/com/posts/Feed.tsx
··· 180 180 ListHeaderComponent?: () => JSX.Element 181 181 extraData?: any 182 182 savedFeedConfig?: AppBskyActorDefs.SavedFeed 183 + outsideHeaderOffset?: number 183 184 }): React.ReactNode => { 184 185 const theme = useTheme() 185 186 const {track} = useAnalytics()
+1 -1
src/view/com/posts/FeedItem.tsx
··· 356 356 postAuthor={post.author} 357 357 onOpenEmbed={onOpenEmbed} 358 358 /> 359 - {__DEV__ && gate('videos') && ( 359 + {gate('video_debug') && ( 360 360 <VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" /> 361 361 )} 362 362 <PostCtrls
+5
src/view/com/util/List.tsx
··· 5 5 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 6 6 import {usePalette} from '#/lib/hooks/usePalette' 7 7 import {useScrollHandlers} from '#/lib/ScrollContext' 8 + import {useDedupe} from 'lib/hooks/useDedupe' 8 9 import {addStyle} from 'lib/styles' 10 + import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' 9 11 import {FlatList_INTERNAL} from './Views' 10 12 11 13 export type ListMethods = FlatList_INTERNAL ··· 47 49 ) { 48 50 const isScrolledDown = useSharedValue(false) 49 51 const pal = usePalette('default') 52 + const dedupe = useDedupe() 50 53 51 54 function handleScrolledDownChange(didScrollDown: boolean) { 52 55 onScrolledDownChange?.(didScrollDown) ··· 77 80 runOnJS(handleScrolledDownChange)(didScrollDown) 78 81 } 79 82 } 83 + 84 + runOnJS(dedupe)(updateActiveViewAsync) 80 85 }, 81 86 // Note: adding onMomentumBegin here makes simulator scroll 82 87 // lag on Android. So either don't add it, or figure out why.
+24 -23
src/view/com/util/post-embeds/VideoEmbed.tsx
··· 1 - import React, {useCallback} from 'react' 1 + import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 + import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' 6 7 import {atoms as a, useTheme} from '#/alf' 7 8 import {Button, ButtonIcon} from '#/components/Button' 8 9 import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' 10 + import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' 9 11 import {useActiveVideoView} from './ActiveVideoContext' 10 - import {VideoEmbedInner} from './VideoEmbedInner' 11 12 12 13 export function VideoEmbed({source}: {source: string}) { 13 14 const t = useTheme() 14 15 const {active, setActive} = useActiveVideoView({source}) 15 16 const {_} = useLingui() 16 17 17 - const onPress = useCallback(() => setActive(), [setActive]) 18 - 19 18 return ( 20 19 <View 21 20 style={[ ··· 26 25 t.atoms.bg_contrast_25, 27 26 a.my_xs, 28 27 ]}> 29 - {active ? ( 30 - <VideoEmbedInner 31 - source={source} 32 - // web only 33 - active={active} 34 - setActive={setActive} 35 - onScreen={true} 36 - /> 37 - ) : ( 38 - <Button 39 - style={[a.flex_1, t.atoms.bg_contrast_25]} 40 - onPress={onPress} 41 - label={_(msg`Play video`)} 42 - variant="ghost" 43 - color="secondary" 44 - size="large"> 45 - <ButtonIcon icon={PlayIcon} /> 46 - </Button> 47 - )} 28 + <VisibilityView 29 + enabled={true} 30 + onChangeStatus={isActive => { 31 + if (isActive) { 32 + setActive() 33 + } 34 + }}> 35 + {active ? ( 36 + <VideoEmbedInnerNative /> 37 + ) : ( 38 + <Button 39 + style={[a.flex_1, t.atoms.bg_contrast_25]} 40 + onPress={setActive} 41 + label={_(msg`Play video`)} 42 + variant="ghost" 43 + color="secondary" 44 + size="large"> 45 + <ButtonIcon icon={PlayIcon} /> 46 + </Button> 47 + )} 48 + </VisibilityView> 48 49 </View> 49 50 ) 50 51 }
+5 -3
src/view/com/util/post-embeds/VideoEmbed.web.tsx
··· 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 + import { 7 + HLSUnsupportedError, 8 + VideoEmbedInnerWeb, 9 + } from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' 6 10 import {atoms as a, useTheme} from '#/alf' 7 11 import {Button, ButtonText} from '#/components/Button' 8 12 import {Text} from '#/components/Typography' 9 13 import {ErrorBoundary} from '../ErrorBoundary' 10 14 import {useActiveVideoView} from './ActiveVideoContext' 11 - import {VideoEmbedInner} from './VideoEmbedInner' 12 - import {HLSUnsupportedError} from './VideoEmbedInner.web' 13 15 14 16 export function VideoEmbed({source}: {source: string}) { 15 17 const t = useTheme() ··· 60 62 <ViewportObserver 61 63 sendPosition={sendPosition} 62 64 isAnyViewActive={currentActiveView !== null}> 63 - <VideoEmbedInner 65 + <VideoEmbedInnerWeb 64 66 source={source} 65 67 active={active} 66 68 setActive={setActive}
-143
src/view/com/util/post-embeds/VideoEmbedInner.tsx
··· 1 - import React, {useCallback, useEffect, useRef, useState} from 'react' 2 - import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native' 3 - import Animated, { 4 - measure, 5 - runOnJS, 6 - useAnimatedRef, 7 - useFrameCallback, 8 - useSharedValue, 9 - } from 'react-native-reanimated' 10 - import {VideoPlayer, VideoView} from 'expo-video' 11 - 12 - import {atoms as a} from '#/alf' 13 - import {Text} from '#/components/Typography' 14 - import {useVideoPlayer} from './VideoPlayerContext' 15 - 16 - export function VideoEmbedInner({}: { 17 - source: string 18 - active: boolean 19 - setActive: () => void 20 - onScreen: boolean 21 - }) { 22 - const player = useVideoPlayer() 23 - const aref = useAnimatedRef<Animated.View>() 24 - const {height: windowHeight} = useWindowDimensions() 25 - const hasLeftView = useSharedValue(false) 26 - const ref = useRef<VideoView>(null) 27 - 28 - const onEnterView = useCallback(() => { 29 - if (player.status === 'readyToPlay') { 30 - player.play() 31 - } 32 - }, [player]) 33 - 34 - const onLeaveView = useCallback(() => { 35 - player.pause() 36 - }, [player]) 37 - 38 - const enterFullscreen = useCallback(() => { 39 - if (ref.current) { 40 - ref.current.enterFullscreen() 41 - } 42 - }, []) 43 - 44 - useFrameCallback(() => { 45 - const measurement = measure(aref) 46 - 47 - if (measurement) { 48 - if (hasLeftView.value) { 49 - // Check if the video is in view 50 - if ( 51 - measurement.pageY >= 0 && 52 - measurement.pageY + measurement.height <= windowHeight 53 - ) { 54 - runOnJS(onEnterView)() 55 - hasLeftView.value = false 56 - } 57 - } else { 58 - // Check if the video is out of view 59 - if ( 60 - measurement.pageY + measurement.height < 0 || 61 - measurement.pageY > windowHeight 62 - ) { 63 - runOnJS(onLeaveView)() 64 - hasLeftView.value = true 65 - } 66 - } 67 - } 68 - }) 69 - 70 - return ( 71 - <Animated.View 72 - style={[a.flex_1, a.relative]} 73 - ref={aref} 74 - collapsable={false}> 75 - <VideoView 76 - ref={ref} 77 - player={player} 78 - style={a.flex_1} 79 - nativeControls={true} 80 - /> 81 - <VideoControls player={player} enterFullscreen={enterFullscreen} /> 82 - </Animated.View> 83 - ) 84 - } 85 - 86 - function VideoControls({ 87 - player, 88 - enterFullscreen, 89 - }: { 90 - player: VideoPlayer 91 - enterFullscreen: () => void 92 - }) { 93 - const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) 94 - 95 - useEffect(() => { 96 - const interval = setInterval(() => { 97 - setCurrentTime(Math.floor(player.duration - player.currentTime)) 98 - // how often should we update the time? 99 - // 1000 gets out of sync with the video time 100 - }, 250) 101 - 102 - return () => { 103 - clearInterval(interval) 104 - } 105 - }, [player]) 106 - 107 - const minutes = Math.floor(currentTime / 60) 108 - const seconds = String(currentTime % 60).padStart(2, '0') 109 - 110 - return ( 111 - <View style={[a.absolute, a.inset_0]}> 112 - <View style={styles.timeContainer} pointerEvents="none"> 113 - <Text style={styles.timeElapsed}> 114 - {minutes}:{seconds} 115 - </Text> 116 - </View> 117 - <Pressable 118 - onPress={enterFullscreen} 119 - style={a.flex_1} 120 - accessibilityLabel="Video" 121 - accessibilityHint="Tap to enter full screen" 122 - accessibilityRole="button" 123 - /> 124 - </View> 125 - ) 126 - } 127 - 128 - const styles = StyleSheet.create({ 129 - timeContainer: { 130 - backgroundColor: 'rgba(0, 0, 0, 0.75)', 131 - borderRadius: 6, 132 - paddingHorizontal: 6, 133 - paddingVertical: 3, 134 - position: 'absolute', 135 - left: 5, 136 - bottom: 5, 137 - }, 138 - timeElapsed: { 139 - color: 'white', 140 - fontSize: 12, 141 - fontWeight: 'bold', 142 - }, 143 - })
+10 -4
src/view/com/util/post-embeds/VideoEmbedInner.web.tsx src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 5 5 import {atoms as a} from '#/alf' 6 6 import {Controls} from './VideoWebControls' 7 7 8 - export function VideoEmbedInner({ 8 + export function VideoEmbedInnerWeb({ 9 9 source, 10 10 active, 11 11 setActive, 12 12 onScreen, 13 13 }: { 14 14 source: string 15 - active: boolean 16 - setActive: () => void 17 - onScreen: boolean 15 + active?: boolean 16 + setActive?: () => void 17 + onScreen?: boolean 18 18 }) { 19 + if (active == null || setActive == null || onScreen == null) { 20 + throw new Error( 21 + 'active, setActive, and onScreen are required VideoEmbedInner props on web.', 22 + ) 23 + } 24 + 19 25 const containerRef = useRef<HTMLDivElement>(null) 20 26 const ref = useRef<HTMLVideoElement>(null) 21 27 const [focused, setFocused] = useState(false)
+96
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 1 + import React, {useEffect, useRef, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {VideoPlayer, VideoView} from 'expo-video' 4 + 5 + import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext' 6 + import {android, atoms as a} from '#/alf' 7 + import {Text} from '#/components/Typography' 8 + 9 + export function VideoEmbedInnerNative() { 10 + const player = useVideoPlayer() 11 + const ref = useRef<VideoView>(null) 12 + 13 + return ( 14 + <View style={[a.flex_1, a.relative]} collapsable={false}> 15 + <VideoView 16 + ref={ref} 17 + player={player} 18 + style={a.flex_1} 19 + nativeControls={true} 20 + /> 21 + <Controls 22 + player={player} 23 + enterFullscreen={() => ref.current?.enterFullscreen()} 24 + /> 25 + </View> 26 + ) 27 + } 28 + 29 + function Controls({ 30 + player, 31 + enterFullscreen, 32 + }: { 33 + player: VideoPlayer 34 + enterFullscreen: () => void 35 + }) { 36 + const [duration, setDuration] = useState(() => Math.floor(player.duration)) 37 + const [currentTime, setCurrentTime] = useState(() => 38 + Math.floor(player.currentTime), 39 + ) 40 + 41 + const timeRemaining = duration - currentTime 42 + const minutes = Math.floor(timeRemaining / 60) 43 + const seconds = String(timeRemaining % 60).padStart(2, '0') 44 + 45 + useEffect(() => { 46 + const interval = setInterval(() => { 47 + // duration gets reset to 0 on loop 48 + if (player.duration) setDuration(Math.floor(player.duration)) 49 + setCurrentTime(Math.floor(player.currentTime)) 50 + // how often should we update the time? 51 + // 1000 gets out of sync with the video time 52 + }, 250) 53 + 54 + return () => { 55 + clearInterval(interval) 56 + } 57 + }, [player]) 58 + 59 + if (isNaN(timeRemaining)) { 60 + return null 61 + } 62 + 63 + return ( 64 + <View style={[a.absolute, a.inset_0]}> 65 + <View 66 + style={[ 67 + { 68 + backgroundColor: 'rgba(0, 0, 0, 0.75', 69 + borderRadius: 6, 70 + paddingHorizontal: 6, 71 + paddingVertical: 3, 72 + position: 'absolute', 73 + left: 5, 74 + bottom: 5, 75 + }, 76 + ]} 77 + pointerEvents="none"> 78 + <Text 79 + style={[ 80 + {color: 'white', fontSize: 12}, 81 + a.font_bold, 82 + android({lineHeight: 1.25}), 83 + ]}> 84 + {minutes}:{seconds} 85 + </Text> 86 + </View> 87 + <Pressable 88 + onPress={enterFullscreen} 89 + style={a.flex_1} 90 + accessibilityLabel="Video" 91 + accessibilityHint="Tap to enter full screen" 92 + accessibilityRole="button" 93 + /> 94 + </View> 95 + ) 96 + }
+3
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
··· 1 + export function VideoEmbedInnerNative() { 2 + throw new Error('VideoEmbedInnerNative may not be used on native.') 3 + }
+3
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
··· 1 + export function VideoEmbedInnerWeb() { 2 + throw new Error('VideoEmbedInnerWeb may not be used on native.') 3 + }
src/view/com/util/post-embeds/VideoWebControls.tsx src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
+2 -2
src/view/com/util/post-embeds/VideoWebControls.web.tsx src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
··· 11 11 import {useLingui} from '@lingui/react' 12 12 import type Hls from 'hls.js' 13 13 14 - import {isIPhoneWeb} from '#/platform/detection' 14 + import {isIPhoneWeb} from 'platform/detection' 15 15 import { 16 16 useAutoplayDisabled, 17 17 useSetSubtitlesEnabled, 18 18 useSubtitlesEnabled, 19 - } from '#/state/preferences' 19 + } from 'state/preferences' 20 20 import {atoms as a, useTheme, web} from '#/alf' 21 21 import {Button} from '#/components/Button' 22 22 import {useInteractionState} from '#/components/hooks/useInteractionState'