From d3d235023ec4dd779c047e88a6b268fe5aa1172f Mon Sep 17 00:00:00 2001 From: Samuel Newman <10959775+mozzius@users.noreply.github.com> Date: Wed, 3 Jul 2024 21:11:26 +0100 Subject: [PATCH] polling-based active video approach --- .../util/post-embeds/ActiveVideoContext.tsx | 124 ++++++++++++------ src/view/com/util/post-embeds/VideoEmbed.tsx | 2 +- .../com/util/post-embeds/VideoEmbed.web.tsx | 58 +++----- 3 files changed, 100 insertions(+), 84 deletions(-) diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx index 8e5f094ecb..212b1bcd57 100644 --- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx @@ -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(null) const [source, setSource] = useState(null) + const {height: windowHeight} = useWindowDimensions() - const [canBeUsurped, setCanBeUsurped] = useState(false) - const requestedViews = useRef<{[key: string]: {src: string; y: number}}>({}) - const timeoutRef = useRef>() + const measurementCallbacks = useRef 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 +} diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index f0d8f477c5..82d8772723 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -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]) diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index f836e7757f..a0d5dfa2c5 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -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(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 (