diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 82b2503eb1..b49c49e4ab 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' import Hls, {Events, FragChangedData, Fragment} from 'hls.js' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Controls} from './web-controls/VideoControls' @@ -30,114 +31,13 @@ export function VideoEmbedInnerWeb({ throw error } - const hlsRef = useRef(undefined) - const [lowQualityFragments, setLowQualityFragments] = useState([]) - - useEffect(() => { - if (!videoRef.current) return - if (!Hls.isSupported()) throw new HLSUnsupportedError() - - const hls = new Hls({ - maxMaxBufferLength: 10, // only load 10s ahead - // note: the amount buffered is affected by both maxBufferLength and maxBufferSize - // it will buffer until it it's greater than *both* of those values - // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead - }) - hlsRef.current = hls - - hls.attachMedia(videoRef.current) - hls.loadSource(embed.playlist) - - // initial value, later on it's managed by Controls - hls.autoLevelCapping = 0 - - // manually loop, so if we've flushed the first buffer it doesn't get confused - const abortController = new AbortController() - const {signal} = abortController - videoRef.current.addEventListener( - 'ended', - function () { - this.currentTime = 0 - this.play() - }, - {signal}, - ) - - hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { - if (data.subtitleTracks.length > 0) { - setHasSubtitleTrack(true) - } - }) - - hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { - if (frag.level === 0) { - setLowQualityFragments(prev => [...prev, frag]) - } - }) - - hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - if ( - data.details === 'manifestLoadError' && - data.response?.code === 404 - ) { - setError(new VideoNotFoundError()) - } else { - setError(data.error) - } - } else { - console.error(data.error) - } - }) - - return () => { - hlsRef.current = undefined - hls.detachMedia() - hls.destroy() - abortController.abort() - } - }, [embed.playlist]) - - // purge low quality segments from buffer on next frag change - useEffect(() => { - if (!hlsRef.current) return - - const current = hlsRef.current - - if (focused) { - function fragChanged( - _event: Events.FRAG_CHANGED, - {frag}: FragChangedData, - ) { - // if the current quality level goes above 0, flush the low quality segments - if (current.nextAutoLevel > 0) { - const flushed: Fragment[] = [] - - for (const lowQualFrag of lowQualityFragments) { - // avoid if close to the current fragment - if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { - return - } - - current.trigger(Hls.Events.BUFFER_FLUSHING, { - startOffset: lowQualFrag.start, - endOffset: lowQualFrag.end, - type: 'video', - }) - - flushed.push(lowQualFrag) - } - - setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) - } - } - current.on(Hls.Events.FRAG_CHANGED, fragChanged) - - return () => { - current.off(Hls.Events.FRAG_CHANGED, fragChanged) - } - } - }, [focused, lowQualityFragments]) + const hlsRef = useHLS({ + focused, + playlist: embed.playlist, + setHasSubtitleTrack, + setError, + videoRef, + }) return ( @@ -198,3 +98,120 @@ export class VideoNotFoundError extends Error { super('Video not found') } } + +function useHLS({ + focused, + playlist, + setHasSubtitleTrack, + setError, + videoRef, +}: { + focused: boolean + playlist: string + setHasSubtitleTrack: (v: boolean) => void + setError: (v: Error | null) => void + videoRef: React.RefObject +}) { + const hlsRef = useRef(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState([]) + + // purge low quality segments from buffer on next frag change + const handleFragChange = useNonReactiveCallback( + (_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => { + if (!hlsRef.current) return + const hls = hlsRef.current + + if (focused && hls.nextAutoLevel > 0) { + // if the current quality level goes above 0, flush the low quality segments + const flushed: Fragment[] = [] + + for (const lowQualFrag of lowQualityFragments) { + // avoid if close to the current fragment + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { + continue + } + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + + flushed.push(lowQualFrag) + } + + setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) + } + }, + ) + + useEffect(() => { + if (!videoRef.current) return + if (!Hls.isSupported()) throw new HLSUnsupportedError() + + const hls = new Hls({ + maxMaxBufferLength: 10, // only load 10s ahead + // note: the amount buffered is affected by both maxBufferLength and maxBufferSize + // it will buffer until it it's greater than *both* of those values + // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead + }) + hlsRef.current = hls + + hls.attachMedia(videoRef.current) + hls.loadSource(playlist) + + // initial value, later on it's managed by Controls + hls.autoLevelCapping = 0 + + // manually loop, so if we've flushed the first buffer it doesn't get confused + const abortController = new AbortController() + const {signal} = abortController + const videoNode = videoRef.current + videoNode.addEventListener( + 'ended', + function () { + videoNode.currentTime = 0 + videoNode.play() + }, + {signal}, + ) + + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { + if (data.subtitleTracks.length > 0) { + setHasSubtitleTrack(true) + } + }) + + hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { + if (frag.level === 0) { + setLowQualityFragments(prev => [...prev, frag]) + } + }) + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + if ( + data.details === 'manifestLoadError' && + data.response?.code === 404 + ) { + setError(new VideoNotFoundError()) + } else { + setError(data.error) + } + } else { + console.error(data.error) + } + }) + + hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() + abortController.abort() + } + }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange]) + + return hlsRef +}