new intersectionobserver approach - wip

This commit is contained in:
Samuel Newman
2024-07-25 20:04:38 +01:00
parent f0aa636762
commit dcd5a6a188
4 changed files with 155 additions and 152 deletions
@@ -1,11 +1,4 @@
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import React, {useCallback, useId, useMemo, useRef, useState} from 'react'
import {useWindowDimensions} from 'react-native'
import {isNative} from '#/platform/detection'
@@ -14,83 +7,16 @@ import {VideoPlayerProvider} from './VideoPlayerContext'
const ActiveVideoContext = React.createContext<{
activeViewId: string | null
setActiveView: (viewId: string, src: string) => void
registerMeasurementCallback: (
viewId: string,
callback: () => DOMRectReadOnly | void,
) => {remove: () => void}
sendViewPosition: (viewId: string, y: number) => void
} | null>(null)
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
const [activeViewId, setActiveViewId] = useState<string | null>(null)
const activeViewLocationRef = useRef(Infinity)
const [source, setSource] = useState<string | null>(null)
const [manuallySet, setManuallySet] = useState(false)
const {height: windowHeight} = useWindowDimensions()
const measurementCallbacks = useRef<Record<string, () => DOMRect | void>>({})
useEffect(() => {
if (isNative) return
const findAndActivateVideo = () => {
if (Object.keys(measurementCallbacks.current).length === 0) {
return
}
const locations = Object.entries(measurementCallbacks.current).map(
([id, callback]) => ({
id,
rect: callback(),
}),
)
const videosInView = locations.filter(
({rect}) => rect && rect.top >= 0 && rect.bottom <= windowHeight,
)
const active = closestToMiddle(windowHeight, videosInView)
if (active) {
// change the active view if it's not already active
// if the user has manually set the active view, don't change it
// i.e. if the user clicks on a video at the bottom of the screen
// it takes precidence over the video that is closest to the middle
setActiveViewId(activeView => {
if (activeView !== active.id) {
if (manuallySet && videosInView.find(({id}) => id === activeView)) {
return activeView
}
setManuallySet(false)
return active.id
}
return activeView
})
} else {
// if no videos are in view, unset the active view
// if the active view is partially in view, keep it active
setActiveViewId(activeView => {
const activeRect = locations.find(({id}) => id === activeView)?.rect
if (activeRect) {
const topCond = activeRect.top + activeRect.height / 2 >= 0
const bottomCond =
activeRect.bottom - activeRect.height / 2 <= windowHeight
if (topCond && bottomCond) {
return activeView
}
}
return null
})
}
}
findAndActivateVideo()
const interval = setInterval(findAndActivateVideo, 1000)
return () => {
clearInterval(interval)
}
}, [manuallySet, windowHeight])
const value = useMemo(
() => ({
activeViewId,
@@ -98,17 +24,48 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
setActiveViewId(viewId)
setSource(src)
setManuallySet(true)
// we don't know the exact position, but it's definitely on screen
// so just guess that it's in the middle. Any value is fine
// so long as it's not offscreen
activeViewLocationRef.current = windowHeight / 2
},
registerMeasurementCallback: (viewId: string, callback: () => void) => {
measurementCallbacks.current[viewId] = callback
return {
remove: () => {
delete measurementCallbacks.current[viewId]
},
sendViewPosition: (viewId: string, y: number) => {
if (isNative) return
// console.log(
// 'sendViewPosition',
// viewId,
// y,
// activeViewId,
// activeViewLocationRef.current,
// )
if (viewId === activeViewId) {
activeViewLocationRef.current = y
} else {
if (
distanceToIdealPosition(y) <
distanceToIdealPosition(activeViewLocationRef.current)
) {
// if the old view was manually set, only usurp if the old view is offscreen
if (manuallySet && withinViewport(activeViewLocationRef.current))
return
setActiveViewId(viewId)
activeViewLocationRef.current = y
}
}
function distanceToIdealPosition(yPos: number) {
return Math.abs(yPos - windowHeight / 3)
}
function withinViewport(yPos: number) {
return yPos > 0 && yPos < windowHeight
}
},
}),
[activeViewId],
[activeViewId, windowHeight, manuallySet],
)
return (
@@ -120,59 +77,23 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
)
}
export function useActiveVideoView({
source,
measure,
}: {
source: string
measure: () => void
}) {
export function useActiveVideoView({source}: {source: string}) {
const context = React.useContext(ActiveVideoContext)
if (!context) {
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
}
const id = useId()
useEffect(() => {
const sub = context.registerMeasurementCallback(id, measure)
return () => {
sub.remove()
}
}, [context, id, measure])
return {
active: context.activeViewId === id,
setActive: useCallback(
() => context.setActiveView(id, source),
[context, id, source],
),
currentActiveView: context.activeViewId,
sendPosition: useCallback(
(y: number) => context.sendViewPosition(id, y),
[context, id],
),
}
}
function closestToMiddle(
windowHeight: number,
elements: {id: string; rect: DOMRect | void}[],
) {
// actually gonna target 1/3 of the way down the screen
// so that the top post probably is the one that gets activated
const middle = windowHeight / 3
let closest = elements[0]
for (const element of elements) {
if (!element.rect) {
continue
}
if (!closest.rect) {
closest = element
continue
}
if (
Math.abs(element.rect.top + element.rect.height / 2 - middle) <
Math.abs(closest.rect.top + closest.rect.height / 2 - middle)
) {
closest = element
}
}
return closest
}
@@ -12,45 +12,52 @@ import {VideoEmbedInner} from './VideoEmbedInner'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {active, setActive} = useActiveVideoView({
const {active, setActive, sendPosition} = useActiveVideoView({
source,
measure: () => {
if (ref.current) {
return ref.current.getBoundingClientRect()
}
},
})
const [hasBeenActive, setHasBeenActive] = useState(false)
const [onScreen, setOnScreen] = useState(false)
const {_} = useLingui()
const onPress = useCallback(() => setActive(), [setActive])
useEffect(() => {
if (active) {
setHasBeenActive(true)
}
}, [active])
if (!ref.current) return
const observer = new IntersectionObserver(
entries => {
console.log('OUTER')
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
)
},
{threshold: 0.5},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
return (
<View
style={[
a.w_full,
a.rounded_sm,
{aspectRatio: 16 / 9},
a.overflow_hidden,
t.atoms.bg_contrast_25,
a.my_xs,
]}>
<div ref={ref} style={{display: 'flex', flex: 1}}>
{hasBeenActive ? (
{onScreen || active ? (
<VideoEmbedInner
source={source}
active={active}
setActive={setActive}
sendPosition={sendPosition}
onScreen={onScreen}
/>
) : (
<Button
style={[a.flex_1, t.atoms.bg_contrast_25]}
style={[a.flex_1, t.atoms.bg_contrast_25, a.rounded_sm]}
onPress={onPress}
label={_(msg`Play video`)}
variant="ghost"
@@ -17,6 +17,8 @@ export function VideoEmbedInner({}: {
source: string
active: boolean
setActive: () => void
sendPosition: (position: number) => void
onScreen: boolean
}) {
const player = useVideoPlayer()
const aref = useAnimatedRef<Animated.View>()
@@ -5,13 +5,73 @@ import Hls from 'hls.js'
import {atoms as a, useTheme} from '#/alf'
export function VideoEmbedInner({
source,
active,
setActive,
sendPosition,
...props
}: {
source: string
active: boolean
setActive: () => void
sendPosition: (position: number) => void
onScreen: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
// Send position when scrolling. This is done with an IntersectionObserver
// observing a div of 100vh height
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
if (!entry) return
console.log('observing', entry.intersectionRatio)
const position =
entry.boundingClientRect.y + entry.boundingClientRect.height / 2
sendPosition(position)
},
{threshold: Array.from({length: 101}, (_, i) => i / 100)},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
// In case scrolling hasn't started yet, send up the position
useEffect(() => {
if (ref.current && !active) {
const rect = ref.current.getBoundingClientRect()
const position = rect.top + rect.height / 2
sendPosition(position)
}
}, [active, sendPosition])
return (
<View style={[a.flex_1, a.flex_row]}>
<VideoPlayer active={active} {...props} />
<div
ref={ref}
style={{
position: 'absolute',
top: 'calc(50% - 50vh)',
height: '100vh',
width: 10,
background: 'green',
}}
/>
</View>
)
}
export function VideoPlayer({
source,
active,
setActive,
onScreen,
}: {
source: string
active: boolean
setActive: () => void
onScreen: boolean
}) {
const [hls] = useState(() => new Hls())
const ref = useRef<HTMLVideoElement>(null)
@@ -31,24 +91,28 @@ export function VideoEmbedInner({
}, [source, hls])
useEffect(() => {
if (ref.current) {
if (
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
Hls.isSupported()
) {
hls.attachMedia(ref.current)
if (
ref.current &&
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
Hls.isSupported()
) {
hls.attachMedia(ref.current)
return () => {
hls.detachMedia()
}
}
}, [source, hls])
useEffect(() => {
if (active) {
ref.current?.play()
if (!ref.current) return
if (active && onScreen) {
// ref.current?.play()
} else {
ref.current?.pause()
setFocused(false)
}
}, [active])
}, [active, onScreen])
return (
<View
@@ -60,6 +124,15 @@ export function VideoEmbedInner({
t.atoms.bg_contrast_25,
a.my_xs,
]}>
<div
style={{
position: 'absolute',
inset: 0,
background: active ? 'red' : 'blue',
opacity: 0.5,
pointerEvents: 'none',
}}
/>
<video
src={source}
ref={ref}