polling-based active video approach
This commit is contained in:
@@ -6,29 +6,56 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import {useWindowDimensions} from 'react-native'
|
||||||
|
|
||||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
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
|
||||||
requestActive: (viewId: string, src: string, y: number) => void
|
registerMeasurementCallback: (
|
||||||
allowUsurp: (viewId: string) => 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 [source, setSource] = useState<string | null>(null)
|
const [source, setSource] = useState<string | null>(null)
|
||||||
|
const {height: windowHeight} = useWindowDimensions()
|
||||||
|
|
||||||
const [canBeUsurped, setCanBeUsurped] = useState(false)
|
const measurementCallbacks = useRef<Record<string, () => DOMRect | void>>({})
|
||||||
const requestedViews = useRef<{[key: string]: {src: string; y: number}}>({})
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
const findAndActivateVideo = () => {
|
||||||
clearTimeout(timeoutRef.current)
|
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(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -36,36 +63,17 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
|||||||
setActiveView: (viewId: string, src: string) => {
|
setActiveView: (viewId: string, src: string) => {
|
||||||
setActiveViewId(viewId)
|
setActiveViewId(viewId)
|
||||||
setSource(src)
|
setSource(src)
|
||||||
setCanBeUsurped(false)
|
|
||||||
},
|
},
|
||||||
requestActive: (viewId: string, src: string, y: number) => {
|
registerMeasurementCallback: (viewId: string, callback: () => void) => {
|
||||||
if (activeViewId && !canBeUsurped) {
|
measurementCallbacks.current[viewId] = callback
|
||||||
return
|
return {
|
||||||
}
|
remove: () => {
|
||||||
if (requestedViews.current[viewId]) {
|
delete measurementCallbacks.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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[activeViewId, canBeUsurped],
|
[activeViewId],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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)
|
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],
|
||||||
),
|
),
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {VideoEmbedInner} from './VideoEmbedInner'
|
|||||||
|
|
||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({source}: {source: string}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {active, setActive} = useActiveVideoView(source)
|
const {active, setActive} = useActiveVideoView({source, measure: () => {}})
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const onPress = useCallback(() => setActive(), [setActive])
|
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 {View} from 'react-native'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
|
|
||||||
@@ -8,12 +8,17 @@ import {useActiveVideoView} from './ActiveVideoContext'
|
|||||||
export function VideoEmbed({source}: {source: string}) {
|
export function VideoEmbed({source}: {source: string}) {
|
||||||
const [hls] = useState(() => new Hls())
|
const [hls] = useState(() => new Hls())
|
||||||
const hasLoaded = useRef(false)
|
const hasLoaded = useRef(false)
|
||||||
const {active, setActive, requestActive, allowUsurp} =
|
|
||||||
useActiveVideoView(source)
|
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
|
const {active, setActive} = useActiveVideoView({
|
||||||
|
source,
|
||||||
|
measure: useCallback(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
return ref.current.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
})
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
// Use HLS.js to play HLS video
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current && active && !hasLoaded.current) {
|
if (ref.current && active && !hasLoaded.current) {
|
||||||
hasLoaded.current = true
|
hasLoaded.current = true
|
||||||
@@ -21,55 +26,22 @@ export function VideoEmbed({source}: {source: string}) {
|
|||||||
ref.current.src = source
|
ref.current.src = source
|
||||||
} else if (Hls.isSupported()) {
|
} else if (Hls.isSupported()) {
|
||||||
hls.loadSource(source)
|
hls.loadSource(source)
|
||||||
hls.attachMedia(ref.current)
|
|
||||||
} else {
|
} else {
|
||||||
// TODO: fallback
|
// TODO: fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [source, active, hls])
|
}, [source, active, hls])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
hls.destroy()
|
|
||||||
}
|
|
||||||
}, [hls])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const observer = new IntersectionObserver(
|
if (
|
||||||
([entry]) => {
|
!ref.current.canPlayType('application/vnd.apple.mpegurl') &&
|
||||||
if (ref.current) {
|
Hls.isSupported()
|
||||||
if (entry.intersectionRatio > 0.25) {
|
) {
|
||||||
if (active) {
|
hls.attachMedia(ref.current)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [active, requestActive, allowUsurp])
|
}, [source, active, hls])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
Reference in New Issue
Block a user