about summary refs log tree commit diff
path: root/src/view/com/util/post-embeds/VideoEmbed.web.tsx
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-08-07 18:47:51 +0100
committerGitHub <noreply@github.com>2024-08-07 18:47:51 +0100
commitfff2c079c2554861764974aaeeb56f79a25ba82a (patch)
tree5c5771bcac37f5ae076e56cab78903d18b108366 /src/view/com/util/post-embeds/VideoEmbed.web.tsx
parentb701e8c68c1122bf138575804af41260ec1c436d (diff)
downloadvoidsky-fff2c079c2554861764974aaeeb56f79a25ba82a.tar.zst
[Videos] Video player - PR #2 - better web support (#4732)
* attempt some sort of "usurping" system

* polling-based active video approach

* split into inner component again

* click to steal active video

* disable findAndActivateVideo on native

* new intersectionobserver approach - wip

* fix types

* disable perf optimisation to allow overflow

* make active player indicator subtler, clean up video utils

* partially fix double-playing

* start working on controls

* fullscreen API

* get buttons working somewhat

* rm source from where it shouldn't be

* use video elem as source of truth

* fix keyboard nav + mute state

* new icons, add fullscreen + time + fix play

* unmount when far offscreen + round 2dp

* listen globally to clicks rather than blur event

* move controls to new file

* reduce quality when not active

* add hover state to buttons

* stop propagation of videoplayer click

* move around autoplay effects

* increase background contrast

* add subtitles button

* add stopPropagation to root of video player

* clean up VideoWebControls

* fix chrome

* change quality based on focused state

* use autoLevelCapping instead of nextLevel

* get subtitle track from stream

* always use hlsjs

* rework hls into a ref

* render player earlier, allowing preload

* add error boundary

* clean up component structure and organisation

* rework fullscreen API

* disable fullscreen on iPhone

* don't play when ready on pause

* debounce buffering

* simplify giant list of event listeners

* update pref

* reduce prop drilling

* minimise rerenders in `ActiveViewContext`

* restore prop drilling

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
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>
+  )
+}