new intersectionobserver approach - wip
This commit is contained in:
@@ -1,11 +1,4 @@
|
|||||||
import React, {
|
import React, {useCallback, useId, useMemo, useRef, useState} from 'react'
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import {useWindowDimensions} from 'react-native'
|
import {useWindowDimensions} from 'react-native'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
@@ -14,83 +7,16 @@ import {VideoPlayerProvider} from './VideoPlayerContext'
|
|||||||
const ActiveVideoContext = React.createContext<{
|
const ActiveVideoContext = React.createContext<{
|
||||||
activeViewId: string | null
|
activeViewId: string | null
|
||||||
setActiveView: (viewId: string, src: string) => void
|
setActiveView: (viewId: string, src: string) => void
|
||||||
registerMeasurementCallback: (
|
sendViewPosition: (viewId: string, y: number) => void
|
||||||
viewId: string,
|
|
||||||
callback: () => DOMRectReadOnly | void,
|
|
||||||
) => {remove: () => void}
|
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||||
|
const activeViewLocationRef = useRef(Infinity)
|
||||||
const [source, setSource] = useState<string | null>(null)
|
const [source, setSource] = useState<string | null>(null)
|
||||||
const [manuallySet, setManuallySet] = useState(false)
|
const [manuallySet, setManuallySet] = useState(false)
|
||||||
const {height: windowHeight} = useWindowDimensions()
|
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(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
activeViewId,
|
activeViewId,
|
||||||
@@ -98,17 +24,48 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
|||||||
setActiveViewId(viewId)
|
setActiveViewId(viewId)
|
||||||
setSource(src)
|
setSource(src)
|
||||||
setManuallySet(true)
|
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) => {
|
sendViewPosition: (viewId: string, y: number) => {
|
||||||
measurementCallbacks.current[viewId] = callback
|
if (isNative) return
|
||||||
return {
|
|
||||||
remove: () => {
|
// console.log(
|
||||||
delete measurementCallbacks.current[viewId]
|
// '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 (
|
return (
|
||||||
@@ -120,59 +77,23 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveVideoView({
|
export function useActiveVideoView({source}: {source: string}) {
|
||||||
source,
|
|
||||||
measure,
|
|
||||||
}: {
|
|
||||||
source: string
|
|
||||||
measure: () => void
|
|
||||||
}) {
|
|
||||||
const context = React.useContext(ActiveVideoContext)
|
const context = React.useContext(ActiveVideoContext)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
||||||
}
|
}
|
||||||
const id = useId()
|
const id = useId()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sub = context.registerMeasurementCallback(id, measure)
|
|
||||||
return () => {
|
|
||||||
sub.remove()
|
|
||||||
}
|
|
||||||
}, [context, id, measure])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active: context.activeViewId === id,
|
active: context.activeViewId === id,
|
||||||
setActive: useCallback(
|
setActive: useCallback(
|
||||||
() => context.setActiveView(id, source),
|
() => context.setActiveView(id, source),
|
||||||
[context, 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}) {
|
export function VideoEmbed({source}: {source: string}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const {active, setActive} = useActiveVideoView({
|
const {active, setActive, sendPosition} = useActiveVideoView({
|
||||||
source,
|
source,
|
||||||
measure: () => {
|
|
||||||
if (ref.current) {
|
|
||||||
return ref.current.getBoundingClientRect()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const [hasBeenActive, setHasBeenActive] = useState(false)
|
const [onScreen, setOnScreen] = useState(false)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const onPress = useCallback(() => setActive(), [setActive])
|
const onPress = useCallback(() => setActive(), [setActive])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (!ref.current) return
|
||||||
setHasBeenActive(true)
|
const observer = new IntersectionObserver(
|
||||||
}
|
entries => {
|
||||||
}, [active])
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.rounded_sm,
|
|
||||||
{aspectRatio: 16 / 9},
|
{aspectRatio: 16 / 9},
|
||||||
a.overflow_hidden,
|
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
<div ref={ref} style={{display: 'flex', flex: 1}}>
|
<div ref={ref} style={{display: 'flex', flex: 1}}>
|
||||||
{hasBeenActive ? (
|
{onScreen || active ? (
|
||||||
<VideoEmbedInner
|
<VideoEmbedInner
|
||||||
source={source}
|
source={source}
|
||||||
active={active}
|
active={active}
|
||||||
setActive={setActive}
|
setActive={setActive}
|
||||||
|
sendPosition={sendPosition}
|
||||||
|
onScreen={onScreen}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
style={[a.flex_1, t.atoms.bg_contrast_25, a.rounded_sm]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
label={_(msg`Play video`)}
|
label={_(msg`Play video`)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function VideoEmbedInner({}: {
|
|||||||
source: string
|
source: string
|
||||||
active: boolean
|
active: boolean
|
||||||
setActive: () => void
|
setActive: () => void
|
||||||
|
sendPosition: (position: number) => void
|
||||||
|
onScreen: boolean
|
||||||
}) {
|
}) {
|
||||||
const player = useVideoPlayer()
|
const player = useVideoPlayer()
|
||||||
const aref = useAnimatedRef<Animated.View>()
|
const aref = useAnimatedRef<Animated.View>()
|
||||||
|
|||||||
@@ -5,13 +5,73 @@ import Hls from 'hls.js'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
|
||||||
export function VideoEmbedInner({
|
export function VideoEmbedInner({
|
||||||
source,
|
|
||||||
active,
|
active,
|
||||||
setActive,
|
sendPosition,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
source: string
|
source: string
|
||||||
active: boolean
|
active: boolean
|
||||||
setActive: () => void
|
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 [hls] = useState(() => new Hls())
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
@@ -31,24 +91,28 @@ export function VideoEmbedInner({
|
|||||||
}, [source, hls])
|
}, [source, hls])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
|
||||||
if (
|
if (
|
||||||
|
ref.current &&
|
||||||
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
|
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
|
||||||
Hls.isSupported()
|
Hls.isSupported()
|
||||||
) {
|
) {
|
||||||
hls.attachMedia(ref.current)
|
hls.attachMedia(ref.current)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hls.detachMedia()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [source, hls])
|
}, [source, hls])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
if (!ref.current) return
|
||||||
ref.current?.play()
|
if (active && onScreen) {
|
||||||
|
// ref.current?.play()
|
||||||
} else {
|
} else {
|
||||||
ref.current?.pause()
|
ref.current?.pause()
|
||||||
setFocused(false)
|
setFocused(false)
|
||||||
}
|
}
|
||||||
}, [active])
|
}, [active, onScreen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -60,6 +124,15 @@ export function VideoEmbedInner({
|
|||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: active ? 'red' : 'blue',
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<video
|
<video
|
||||||
src={source}
|
src={source}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
Reference in New Issue
Block a user