about summary refs log tree commit diff
path: root/src/view/com/util/post-embeds/VideoEmbed.web.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util/post-embeds/VideoEmbed.web.tsx')
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.web.tsx190
1 files changed, 190 insertions, 0 deletions
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
new file mode 100644
index 000000000..08932f91f
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -0,0 +1,190 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {ErrorBoundary} from '../ErrorBoundary'
+import {useActiveVideoView} from './ActiveVideoContext'
+import {VideoEmbedInner} from './VideoEmbedInner'
+import {HLSUnsupportedError} from './VideoEmbedInner.web'
+
+export function VideoEmbed({source}: {source: string}) {
+  const t = useTheme()
+  const ref = useRef<HTMLDivElement>(null)
+  const {active, setActive, sendPosition, currentActiveView} =
+    useActiveVideoView({source})
+  const [onScreen, setOnScreen] = useState(false)
+
+  useEffect(() => {
+    if (!ref.current) return
+    const observer = new IntersectionObserver(
+      entries => {
+        const entry = entries[0]
+        if (!entry) return
+        setOnScreen(entry.isIntersecting)
+        sendPosition(
+          entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
+        )
+      },
+      {threshold: 0.5},
+    )
+    observer.observe(ref.current)
+    return () => observer.disconnect()
+  }, [sendPosition])
+
+  const [key, setKey] = useState(0)
+  const renderError = useCallback(
+    (error: unknown) => (
+      <VideoError error={error} retry={() => setKey(key + 1)} />
+    ),
+    [key],
+  )
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        {aspectRatio: 16 / 9},
+        t.atoms.bg_contrast_25,
+        a.rounded_sm,
+        a.my_xs,
+      ]}>
+      <div
+        ref={ref}
+        style={{display: 'flex', flex: 1, cursor: 'default'}}
+        onClick={evt => evt.stopPropagation()}>
+        <ErrorBoundary renderError={renderError} key={key}>
+          <ViewportObserver
+            sendPosition={sendPosition}
+            isAnyViewActive={currentActiveView !== null}>
+            <VideoEmbedInner
+              source={source}
+              active={active}
+              setActive={setActive}
+              onScreen={onScreen}
+            />
+          </ViewportObserver>
+        </ErrorBoundary>
+      </div>
+    </View>
+  )
+}
+
+/**
+ * Renders a 100vh tall div and watches it with an IntersectionObserver to
+ * send the position of the div when it's near the screen.
+ */
+function ViewportObserver({
+  children,
+  sendPosition,
+  isAnyViewActive,
+}: {
+  children: React.ReactNode
+  sendPosition: (position: number) => void
+  isAnyViewActive?: boolean
+}) {
+  const ref = useRef<HTMLDivElement>(null)
+  const [nearScreen, setNearScreen] = useState(false)
+
+  // Send position when scrolling. This is done with an IntersectionObserver
+  // observing a div of 100vh height
+  useEffect(() => {
+    if (!ref.current) return
+    const observer = new IntersectionObserver(
+      entries => {
+        const entry = entries[0]
+        if (!entry) return
+        const position =
+          entry.boundingClientRect.y + entry.boundingClientRect.height / 2
+        sendPosition(position)
+        setNearScreen(entry.isIntersecting)
+      },
+      {threshold: Array.from({length: 101}, (_, i) => i / 100)},
+    )
+    observer.observe(ref.current)
+    return () => observer.disconnect()
+  }, [sendPosition])
+
+  // In case scrolling hasn't started yet, send up the position
+  useEffect(() => {
+    if (ref.current && !isAnyViewActive) {
+      const rect = ref.current.getBoundingClientRect()
+      const position = rect.y + rect.height / 2
+      sendPosition(position)
+    }
+  }, [isAnyViewActive, sendPosition])
+
+  return (
+    <View style={[a.flex_1, a.flex_row]}>
+      {nearScreen && children}
+      <div
+        ref={ref}
+        style={{
+          position: 'absolute',
+          top: 'calc(50% - 50vh)',
+          left: '50%',
+          height: '100vh',
+          width: 1,
+          pointerEvents: 'none',
+        }}
+      />
+    </View>
+  )
+}
+
+function VideoError({error, retry}: {error: unknown; retry: () => void}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const isHLS = error instanceof HLSUnsupportedError
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        t.atoms.bg_contrast_25,
+        a.justify_center,
+        a.align_center,
+        a.px_lg,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_sm,
+        a.gap_lg,
+      ]}>
+      <Text
+        style={[
+          a.text_center,
+          t.atoms.text_contrast_high,
+          a.text_md,
+          a.leading_snug,
+          {maxWidth: 300},
+        ]}>
+        {isHLS ? (
+          <Trans>
+            Your browser does not support the video format. Please try a
+            different browser.
+          </Trans>
+        ) : (
+          <Trans>
+            An error occurred while loading the video. Please try again later.
+          </Trans>
+        )}
+      </Text>
+      {!isHLS && (
+        <Button
+          onPress={retry}
+          size="small"
+          color="secondary_inverted"
+          variant="solid"
+          label={_(msg`Retry`)}>
+          <ButtonText>
+            <Trans>Retry</Trans>
+          </ButtonText>
+        </Button>
+      )}
+    </View>
+  )
+}