From ddaf2c62bd0fa93e02e8ed87a6a4b73bf4718fb9 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Wed, 25 Sep 2024 05:47:09 +0900
Subject: [Video] Refactor HLS logic (#5468)

* Extract HLS interop into useHLS

* Rename variable

* Move flushing outside an effect

* use continue instead of return

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
---
 .../VideoEmbedInner/VideoEmbedInnerWeb.tsx         | 225 +++++++++++----------
 1 file changed, 121 insertions(+), 104 deletions(-)

(limited to 'src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx')

diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index 82b2503eb..b49c49e4a 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,9 +31,120 @@ export function VideoEmbedInnerWeb({
     throw error
   }
 
+  const hlsRef = useHLS({
+    focused,
+    playlist: embed.playlist,
+    setHasSubtitleTrack,
+    setError,
+    videoRef,
+  })
+
+  return (
+    <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
+      <div ref={containerRef} style={{height: '100%', width: '100%'}}>
+        <figure style={{margin: 0, position: 'absolute', inset: 0}}>
+          <video
+            ref={videoRef}
+            poster={embed.thumbnail}
+            style={{width: '100%', height: '100%', objectFit: 'contain'}}
+            playsInline
+            preload="none"
+            muted={!focused}
+            aria-labelledby={embed.alt ? figId : undefined}
+          />
+          {embed.alt && (
+            <figcaption
+              id={figId}
+              style={{
+                position: 'absolute',
+                width: 1,
+                height: 1,
+                padding: 0,
+                margin: -1,
+                overflow: 'hidden',
+                clip: 'rect(0, 0, 0, 0)',
+                whiteSpace: 'nowrap',
+                borderWidth: 0,
+              }}>
+              {embed.alt}
+            </figcaption>
+          )}
+        </figure>
+        <Controls
+          videoRef={videoRef}
+          hlsRef={hlsRef}
+          active={active}
+          setActive={setActive}
+          focused={focused}
+          setFocused={setFocused}
+          onScreen={onScreen}
+          fullscreenRef={containerRef}
+          hasSubtitleTrack={hasSubtitleTrack}
+        />
+        <MediaInsetBorder />
+      </div>
+    </View>
+  )
+}
+
+export class HLSUnsupportedError extends Error {
+  constructor() {
+    super('HLS is not supported')
+  }
+}
+
+export class VideoNotFoundError extends Error {
+  constructor() {
+    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<HTMLVideoElement>
+}) {
   const hlsRef = useRef<Hls | undefined>(undefined)
   const [lowQualityFragments, setLowQualityFragments] = useState<Fragment[]>([])
 
+  // 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()
@@ -46,7 +158,7 @@ export function VideoEmbedInnerWeb({
     hlsRef.current = hls
 
     hls.attachMedia(videoRef.current)
-    hls.loadSource(embed.playlist)
+    hls.loadSource(playlist)
 
     // initial value, later on it's managed by Controls
     hls.autoLevelCapping = 0
@@ -54,11 +166,12 @@ export function VideoEmbedInnerWeb({
     // 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(
+    const videoNode = videoRef.current
+    videoNode.addEventListener(
       'ended',
       function () {
-        this.currentTime = 0
-        this.play()
+        videoNode.currentTime = 0
+        videoNode.play()
       },
       {signal},
     )
@@ -90,111 +203,15 @@ export function VideoEmbedInnerWeb({
       }
     })
 
+    hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
+
     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
+  }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange])
 
-    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])
-
-  return (
-    <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
-      <div ref={containerRef} style={{height: '100%', width: '100%'}}>
-        <figure style={{margin: 0, position: 'absolute', inset: 0}}>
-          <video
-            ref={videoRef}
-            poster={embed.thumbnail}
-            style={{width: '100%', height: '100%', objectFit: 'contain'}}
-            playsInline
-            preload="none"
-            muted={!focused}
-            aria-labelledby={embed.alt ? figId : undefined}
-          />
-          {embed.alt && (
-            <figcaption
-              id={figId}
-              style={{
-                position: 'absolute',
-                width: 1,
-                height: 1,
-                padding: 0,
-                margin: -1,
-                overflow: 'hidden',
-                clip: 'rect(0, 0, 0, 0)',
-                whiteSpace: 'nowrap',
-                borderWidth: 0,
-              }}>
-              {embed.alt}
-            </figcaption>
-          )}
-        </figure>
-        <Controls
-          videoRef={videoRef}
-          hlsRef={hlsRef}
-          active={active}
-          setActive={setActive}
-          focused={focused}
-          setFocused={setFocused}
-          onScreen={onScreen}
-          fullscreenRef={containerRef}
-          hasSubtitleTrack={hasSubtitleTrack}
-        />
-        <MediaInsetBorder />
-      </div>
-    </View>
-  )
-}
-
-export class HLSUnsupportedError extends Error {
-  constructor() {
-    super('HLS is not supported')
-  }
-}
-
-export class VideoNotFoundError extends Error {
-  constructor() {
-    super('Video not found')
-  }
+  return hlsRef
 }
-- 
cgit 1.4.1

