about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-09-04 16:46:01 -0700
committerGitHub <noreply@github.com>2024-09-04 16:46:01 -0700
commit25566984278d84c28933a4ae2685388734829e01 (patch)
tree0ad0d99272ff73dbe8333b8559ad452cd16598ef /src
parent76f493c27958d5e1008a3a6aa0ca7f959cbe330d (diff)
downloadvoidsky-25566984278d84c28933a4ae2685388734829e01.tar.zst
[Video] Add loading state to player (#5149)
Diffstat (limited to 'src')
-rw-r--r--src/components/icons/common.tsx1
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx196
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx45
3 files changed, 156 insertions, 86 deletions
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index 387115d3a..e83f96f0b 100644
--- a/src/components/icons/common.tsx
+++ b/src/components/icons/common.tsx
@@ -19,6 +19,7 @@ export const sizes = {
   md: 20,
   lg: 24,
   xl: 28,
+  '2xl': 32,
 }
 
 export function useCommonSVGProps(props: Props) {
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index a5bc97f85..d10a6fe69 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,6 +1,7 @@
-import React, {useCallback, useId, useState} from 'react'
+import React, {useCallback, useEffect, useId, useState} from 'react'
 import {View} from 'react-native'
 import {Image} from 'expo-image'
+import {VideoPlayerStatus} from 'expo-video'
 import {AppBskyEmbedVideo} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -10,56 +11,40 @@ import {useGate} from '#/lib/statsig/statsig'
 import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
 import {atoms as a} from '#/alf'
 import {Button} from '#/components/Button'
+import {Loader} from '#/components/Loader'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
 import {ErrorBoundary} from '../ErrorBoundary'
 import {useActiveVideoNative} from './ActiveVideoNativeContext'
 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
 
-export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
-  const {_} = useLingui()
-  const {activeSource, activeViewId, setActiveSource, player} =
-    useActiveVideoNative()
-  const viewId = useId()
+interface Props {
+  embed: AppBskyEmbedVideo.View
+}
 
-  const [isFullscreen, setIsFullscreen] = React.useState(false)
-  const isActive = embed.playlist === activeSource && activeViewId === viewId
+export function VideoEmbed({embed}: Props) {
+  const gate = useGate()
 
   const [key, setKey] = useState(0)
+
   const renderError = useCallback(
     (error: unknown) => (
       <VideoError error={error} retry={() => setKey(key + 1)} />
     ),
     [key],
   )
-  const gate = useGate()
-
-  const onChangeStatus = (isVisible: boolean) => {
-    if (isVisible) {
-      setActiveSource(embed.playlist, viewId)
-      if (!player.playing) {
-        player.play()
-      }
-    } else if (!isFullscreen) {
-      player.muted = true
-      if (player.playing) {
-        player.pause()
-      }
-    }
-  }
-
-  if (!gate('video_view_on_posts')) {
-    return null
-  }
 
   let aspectRatio = 16 / 9
-
   if (embed.aspectRatio) {
     const {width, height} = embed.aspectRatio
     aspectRatio = width / height
     aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
   }
 
+  if (!gate('video_view_on_posts')) {
+    return null
+  }
+
   return (
     <View
       style={[
@@ -71,39 +56,138 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
         a.my_xs,
       ]}>
       <ErrorBoundary renderError={renderError} key={key}>
-        <VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
-          {isActive ? (
-            <VideoEmbedInnerNative
-              embed={embed}
-              isFullscreen={isFullscreen}
-              setIsFullscreen={setIsFullscreen}
-            />
-          ) : (
-            <>
-              <Image
-                source={{uri: embed.thumbnail}}
-                alt={embed.alt}
-                style={a.flex_1}
-                contentFit="cover"
-                accessibilityIgnoresInvertColors
-              />
-              <Button
-                style={[a.absolute, a.inset_0]}
-                onPress={() => {
-                  setActiveSource(embed.playlist, viewId)
-                }}
-                label={_(msg`Play video`)}
-                color="secondary">
-                <PlayButtonIcon />
-              </Button>
-            </>
-          )}
-        </VisibilityView>
+        <InnerWrapper embed={embed} />
       </ErrorBoundary>
     </View>
   )
 }
 
+function InnerWrapper({embed}: Props) {
+  const {_} = useLingui()
+  const {activeSource, activeViewId, setActiveSource, player} =
+    useActiveVideoNative()
+  const viewId = useId()
+
+  const [playerStatus, setPlayerStatus] = useState<VideoPlayerStatus>('loading')
+  const [isMuted, setIsMuted] = useState(player.muted)
+  const [isFullscreen, setIsFullscreen] = React.useState(false)
+  const [timeRemaining, setTimeRemaining] = React.useState(0)
+  const isActive = embed.playlist === activeSource && activeViewId === viewId
+  const isLoading =
+    isActive &&
+    (playerStatus === 'waitingToPlayAtSpecifiedRate' ||
+      playerStatus === 'loading')
+
+  useEffect(() => {
+    if (isActive) {
+      // eslint-disable-next-line @typescript-eslint/no-shadow
+      const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
+        setIsMuted(isMuted)
+      })
+      const timeSub = player.addListener(
+        'timeRemainingChange',
+        secondsRemaining => {
+          setTimeRemaining(secondsRemaining)
+        },
+      )
+      const statusSub = player.addListener(
+        'statusChange',
+        (status, _oldStatus, error) => {
+          setPlayerStatus(status)
+          if (status === 'error') {
+            throw error
+          }
+        },
+      )
+      return () => {
+        volumeSub.remove()
+        timeSub.remove()
+        statusSub.remove()
+      }
+    }
+  }, [player, isActive])
+
+  useEffect(() => {
+    if (!isActive && playerStatus !== 'loading') {
+      setPlayerStatus('loading')
+    }
+  }, [isActive, playerStatus])
+
+  const onChangeStatus = (isVisible: boolean) => {
+    if (isFullscreen) {
+      return
+    }
+
+    if (isVisible) {
+      setActiveSource(embed.playlist, viewId)
+      if (!player.playing) {
+        player.play()
+      }
+    } else {
+      player.muted = true
+      if (player.playing) {
+        player.pause()
+      }
+    }
+  }
+
+  return (
+    <VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
+      {isActive ? (
+        <VideoEmbedInnerNative
+          embed={embed}
+          timeRemaining={timeRemaining}
+          isMuted={isMuted}
+          isFullscreen={isFullscreen}
+          setIsFullscreen={setIsFullscreen}
+        />
+      ) : null}
+      {!isActive || isLoading ? (
+        <View
+          style={[
+            {
+              position: 'absolute',
+              top: 0,
+              bottom: 0,
+              left: 0,
+              right: 0,
+            },
+          ]}>
+          <Image
+            source={{uri: embed.thumbnail}}
+            alt={embed.alt}
+            style={a.flex_1}
+            contentFit="cover"
+            accessibilityIgnoresInvertColors
+          />
+          <Button
+            style={[a.absolute, a.inset_0]}
+            onPress={() => {
+              setActiveSource(embed.playlist, viewId)
+            }}
+            label={_(msg`Play video`)}
+            color="secondary">
+            {isLoading ? (
+              <View
+                style={[
+                  a.rounded_full,
+                  a.p_xs,
+                  a.absolute,
+                  {top: 'auto', left: 'auto'},
+                  {backgroundColor: 'rgba(0,0,0,0.5)'},
+                ]}>
+                <Loader size="2xl" style={{color: 'white'}} />
+              </View>
+            ) : (
+              <PlayButtonIcon />
+            )}
+          </Button>
+        </View>
+      ) : null}
+    </VisibilityView>
+  )
+}
+
 function VideoError({retry}: {error: unknown; retry: () => void}) {
   return (
     <VideoFallback.Container>
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 4fafce1de..3fa159267 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
+import React, {useCallback, useRef} from 'react'
 import {Pressable, View} from 'react-native'
 import Animated, {FadeInDown} from 'react-native-reanimated'
 import {VideoPlayer, VideoView} from 'expo-video'
@@ -22,10 +22,14 @@ export function VideoEmbedInnerNative({
   embed,
   isFullscreen,
   setIsFullscreen,
+  isMuted,
+  timeRemaining,
 }: {
   embed: AppBskyEmbedVideo.View
   isFullscreen: boolean
   setIsFullscreen: (isFullscreen: boolean) => void
+  timeRemaining: number
+  isMuted: boolean
 }) {
   const {_} = useLingui()
   const {player} = useActiveVideoNative()
@@ -73,7 +77,12 @@ export function VideoEmbedInnerNative({
         }
         accessibilityHint=""
       />
-      <VideoControls player={player} enterFullscreen={enterFullscreen} />
+      <VideoControls
+        player={player}
+        enterFullscreen={enterFullscreen}
+        isMuted={isMuted}
+        timeRemaining={timeRemaining}
+      />
     </View>
   )
 }
@@ -81,40 +90,16 @@ export function VideoEmbedInnerNative({
 function VideoControls({
   player,
   enterFullscreen,
+  timeRemaining,
+  isMuted,
 }: {
   player: VideoPlayer
   enterFullscreen: () => void
+  timeRemaining: number
+  isMuted: boolean
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const [isMuted, setIsMuted] = useState(player.muted)
-  const [timeRemaining, setTimeRemaining] = React.useState(0)
-
-  useEffect(() => {
-    // eslint-disable-next-line @typescript-eslint/no-shadow
-    const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
-      setIsMuted(isMuted)
-    })
-    const timeSub = player.addListener(
-      'timeRemainingChange',
-      secondsRemaining => {
-        setTimeRemaining(secondsRemaining)
-      },
-    )
-    const statusSub = player.addListener(
-      'statusChange',
-      (status, _oldStatus, error) => {
-        if (status === 'error') {
-          throw error
-        }
-      },
-    )
-    return () => {
-      volumeSub.remove()
-      timeSub.remove()
-      statusSub.remove()
-    }
-  }, [player])
 
   const onPressFullscreen = useCallback(() => {
     switch (player.status) {