polling-based active video approach

This commit is contained in:
Samuel Newman
2024-07-03 21:11:26 +01:00
parent 8282c434e0
commit d3d235023e
3 changed files with 100 additions and 84 deletions
@@ -6,29 +6,56 @@ import React, {
useRef,
useState,
} from 'react'
import {useWindowDimensions} from 'react-native'
import {VideoPlayerProvider} from './VideoPlayerContext'
const ActiveVideoContext = React.createContext<{
activeViewId: string | null
setActiveView: (viewId: string, src: string) => void
requestActive: (viewId: string, src: string, y: number) => void
allowUsurp: (viewId: string) => void
registerMeasurementCallback: (
viewId: string,
callback: () => DOMRectReadOnly | void,
) => {remove: () => void}
} | null>(null)
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
const [activeViewId, setActiveViewId] = useState<string | null>(null)
const [source, setSource] = useState<string | null>(null)
const {height: windowHeight} = useWindowDimensions()
const [canBeUsurped, setCanBeUsurped] = useState(false)
const requestedViews = useRef<{[key: string]: {src: string; y: number}}>({})
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
const measurementCallbacks = useRef<Record<string, () => DOMRect | void>>({})
useEffect(() => {
return () => {
clearTimeout(timeoutRef.current)
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) {
setActiveViewId(active.id)
}
}
}, [])
findAndActivateVideo()
const interval = setInterval(findAndActivateVideo, 1000)
return () => {
clearInterval(interval)
}
}, [activeViewId, windowHeight])
const value = useMemo(
() => ({
@@ -36,36 +63,17 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
setActiveView: (viewId: string, src: string) => {
setActiveViewId(viewId)
setSource(src)
setCanBeUsurped(false)
},
requestActive: (viewId: string, src: string, y: number) => {
if (activeViewId && !canBeUsurped) {
return
}
if (requestedViews.current[viewId]) {
requestedViews.current[viewId].y = y
return
}
clearTimeout(timeoutRef.current)
requestedViews.current[viewId] = {src, y}
timeoutRef.current = setTimeout(() => {
const sortedViews = Object.entries(requestedViews.current).sort(
([, {y: y1}], [, {y: y2}]) => y1 - y2,
)
const [topViewId, {src: topSrc}] = sortedViews[0]
setActiveViewId(topViewId)
setSource(topSrc)
setCanBeUsurped(false)
requestedViews.current = {}
}, 100)
},
allowUsurp: (viewId: string) => {
if (activeViewId === viewId) {
setCanBeUsurped(true)
registerMeasurementCallback: (viewId: string, callback: () => void) => {
measurementCallbacks.current[viewId] = callback
return {
remove: () => {
delete measurementCallbacks.current[viewId]
},
}
},
}),
[activeViewId, canBeUsurped],
[activeViewId],
)
return (
@@ -77,23 +85,59 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
)
}
export function useActiveVideoView(source: string) {
export function useActiveVideoView({
source,
measure,
}: {
source: string
measure: () => void
}) {
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],
),
requestActive: useCallback(
(y: number) => context.requestActive(id, source, y),
[context, id, source],
),
allowUsurp: useCallback(() => context.allowUsurp(id), [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
}
+1 -1
View File
@@ -11,7 +11,7 @@ import {VideoEmbedInner} from './VideoEmbedInner'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const {active, setActive} = useActiveVideoView(source)
const {active, setActive} = useActiveVideoView({source, measure: () => {}})
const {_} = useLingui()
const onPress = useCallback(() => setActive(), [setActive])
@@ -1,4 +1,4 @@
import React, {useEffect, useRef, useState} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import Hls from 'hls.js'
@@ -8,12 +8,17 @@ import {useActiveVideoView} from './ActiveVideoContext'
export function VideoEmbed({source}: {source: string}) {
const [hls] = useState(() => new Hls())
const hasLoaded = useRef(false)
const {active, setActive, requestActive, allowUsurp} =
useActiveVideoView(source)
const ref = useRef<HTMLVideoElement>(null)
const {active, setActive} = useActiveVideoView({
source,
measure: useCallback(() => {
if (ref.current) {
return ref.current.getBoundingClientRect()
}
}, []),
})
const t = useTheme()
// Use HLS.js to play HLS video
useEffect(() => {
if (ref.current && active && !hasLoaded.current) {
hasLoaded.current = true
@@ -21,55 +26,22 @@ export function VideoEmbed({source}: {source: string}) {
ref.current.src = source
} else if (Hls.isSupported()) {
hls.loadSource(source)
hls.attachMedia(ref.current)
} else {
// TODO: fallback
}
}
}, [source, active, hls])
useEffect(() => {
return () => {
hls.destroy()
}
}, [hls])
useEffect(() => {
if (ref.current) {
const observer = new IntersectionObserver(
([entry]) => {
if (ref.current) {
if (entry.intersectionRatio > 0.25) {
if (active) {
if (ref.current.paused) {
ref.current.play()
}
} else {
requestActive(entry.boundingClientRect.y)
}
} else {
if (active) {
if (!ref.current.paused) {
ref.current.pause()
}
}
}
if (entry.intersectionRatio < 0.75) {
allowUsurp()
}
}
},
{threshold: [0, 0.25, 1]},
)
observer.observe(ref.current)
return () => {
observer.disconnect()
if (
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
Hls.isSupported()
) {
hls.attachMedia(ref.current)
}
}
}, [active, requestActive, allowUsurp])
}, [source, active, hls])
return (
<View