about summary refs log tree commit diff
path: root/src/view/com/util/post-embeds
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util/post-embeds')
-rw-r--r--src/view/com/util/post-embeds/ActiveVideoContext.tsx48
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx44
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.tsx138
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.web.tsx52
-rw-r--r--src/view/com/util/post-embeds/VideoPlayerContext.tsx41
-rw-r--r--src/view/com/util/post-embeds/VideoPlayerContext.web.tsx9
6 files changed, 332 insertions, 0 deletions
diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
new file mode 100644
index 000000000..6804436a7
--- /dev/null
+++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
@@ -0,0 +1,48 @@
+import React, {useCallback, useId, useMemo, useState} from 'react'
+
+import {VideoPlayerProvider} from './VideoPlayerContext'
+
+const ActiveVideoContext = React.createContext<{
+  activeViewId: string | null
+  setActiveView: (viewId: string, src: string) => void
+} | null>(null)
+
+export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
+  const [activeViewId, setActiveViewId] = useState<string | null>(null)
+  const [source, setSource] = useState<string | null>(null)
+
+  const value = useMemo(
+    () => ({
+      activeViewId,
+      setActiveView: (viewId: string, src: string) => {
+        setActiveViewId(viewId)
+        setSource(src)
+      },
+    }),
+    [activeViewId],
+  )
+
+  return (
+    <ActiveVideoContext.Provider value={value}>
+      <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
+        {children}
+      </VideoPlayerProvider>
+    </ActiveVideoContext.Provider>
+  )
+}
+
+export function useActiveVideoView() {
+  const context = React.useContext(ActiveVideoContext)
+  if (!context) {
+    throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
+  }
+  const id = useId()
+
+  return {
+    active: context.activeViewId === id,
+    setActive: useCallback(
+      (source: string) => context.setActiveView(id, source),
+      [context, id],
+    ),
+  }
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
new file mode 100644
index 000000000..5e5293a55
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -0,0 +1,44 @@
+import React, {useCallback} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {useActiveVideoView} from './ActiveVideoContext'
+import {VideoEmbedInner} from './VideoEmbedInner'
+
+export function VideoEmbed({source}: {source: string}) {
+  const t = useTheme()
+  const {active, setActive} = useActiveVideoView()
+  const {_} = useLingui()
+
+  const onPress = useCallback(() => setActive(source), [setActive, source])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_sm,
+        {aspectRatio: 16 / 9},
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+        a.my_xs,
+      ]}>
+      {active ? (
+        <VideoEmbedInner source={source} />
+      ) : (
+        <Button
+          style={[a.flex_1, t.atoms.bg_contrast_25]}
+          onPress={onPress}
+          label={_(msg`Play video`)}
+          variant="ghost"
+          color="secondary"
+          size="large">
+          <ButtonIcon icon={PlayIcon} />
+        </Button>
+      )}
+    </View>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
new file mode 100644
index 000000000..ef0678709
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
@@ -0,0 +1,138 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useFrameCallback,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {VideoPlayer, VideoView} from 'expo-video'
+
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useVideoPlayer} from './VideoPlayerContext'
+
+export const VideoEmbedInner = ({}: {source: string}) => {
+  const player = useVideoPlayer()
+  const aref = useAnimatedRef<Animated.View>()
+  const {height: windowHeight} = useWindowDimensions()
+  const hasLeftView = useSharedValue(false)
+  const ref = useRef<VideoView>(null)
+
+  const onEnterView = useCallback(() => {
+    if (player.status === 'readyToPlay') {
+      player.play()
+    }
+  }, [player])
+
+  const onLeaveView = useCallback(() => {
+    player.pause()
+  }, [player])
+
+  const enterFullscreen = useCallback(() => {
+    if (ref.current) {
+      ref.current.enterFullscreen()
+    }
+  }, [])
+
+  useFrameCallback(() => {
+    const measurement = measure(aref)
+
+    if (measurement) {
+      if (hasLeftView.value) {
+        // Check if the video is in view
+        if (
+          measurement.pageY >= 0 &&
+          measurement.pageY + measurement.height <= windowHeight
+        ) {
+          runOnJS(onEnterView)()
+          hasLeftView.value = false
+        }
+      } else {
+        // Check if the video is out of view
+        if (
+          measurement.pageY + measurement.height < 0 ||
+          measurement.pageY > windowHeight
+        ) {
+          runOnJS(onLeaveView)()
+          hasLeftView.value = true
+        }
+      }
+    }
+  })
+
+  return (
+    <Animated.View
+      style={[a.flex_1, a.relative]}
+      ref={aref}
+      collapsable={false}>
+      <VideoView
+        ref={ref}
+        player={player}
+        style={a.flex_1}
+        nativeControls={true}
+      />
+      <VideoControls player={player} enterFullscreen={enterFullscreen} />
+    </Animated.View>
+  )
+}
+
+function VideoControls({
+  player,
+  enterFullscreen,
+}: {
+  player: VideoPlayer
+  enterFullscreen: () => void
+}) {
+  const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setCurrentTime(Math.floor(player.duration - player.currentTime))
+      // how often should we update the time?
+      // 1000 gets out of sync with the video time
+    }, 250)
+
+    return () => {
+      clearInterval(interval)
+    }
+  }, [player])
+
+  const minutes = Math.floor(currentTime / 60)
+  const seconds = String(currentTime % 60).padStart(2, '0')
+
+  return (
+    <View style={[a.absolute, a.inset_0]}>
+      <View style={styles.timeContainer} pointerEvents="none">
+        <Text style={styles.timeElapsed}>
+          {minutes}:{seconds}
+        </Text>
+      </View>
+      <Pressable
+        onPress={enterFullscreen}
+        style={a.flex_1}
+        accessibilityLabel="Video"
+        accessibilityHint="Tap to enter full screen"
+        accessibilityRole="button"
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  timeContainer: {
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderRadius: 6,
+    paddingHorizontal: 6,
+    paddingVertical: 3,
+    position: 'absolute',
+    left: 5,
+    bottom: 5,
+  },
+  timeElapsed: {
+    color: 'white',
+    fontSize: 12,
+    fontWeight: 'bold',
+  },
+})
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
new file mode 100644
index 000000000..cb02743c6
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
@@ -0,0 +1,52 @@
+import React, {useEffect, useRef} from 'react'
+import Hls from 'hls.js'
+
+import {atoms as a} from '#/alf'
+
+export const VideoEmbedInner = ({source}: {source: string}) => {
+  const ref = useRef<HTMLVideoElement>(null)
+
+  // Use HLS.js to play HLS video
+  useEffect(() => {
+    if (ref.current) {
+      if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
+        ref.current.src = source
+      } else if (Hls.isSupported()) {
+        var hls = new Hls()
+        hls.loadSource(source)
+        hls.attachMedia(ref.current)
+      } else {
+        // TODO: fallback
+      }
+    }
+  }, [source])
+
+  useEffect(() => {
+    if (ref.current) {
+      const observer = new IntersectionObserver(
+        ([entry]) => {
+          if (ref.current) {
+            if (entry.isIntersecting) {
+              if (ref.current.paused) {
+                ref.current.play()
+              }
+            } else {
+              if (!ref.current.paused) {
+                ref.current.pause()
+              }
+            }
+          }
+        },
+        {threshold: 0},
+      )
+
+      observer.observe(ref.current)
+
+      return () => {
+        observer.disconnect()
+      }
+    }
+  }, [])
+
+  return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
new file mode 100644
index 000000000..bc5d9d370
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -0,0 +1,41 @@
+import React, {useContext, useEffect} from 'react'
+import type {VideoPlayer} from 'expo-video'
+import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
+
+const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
+
+export function VideoPlayerProvider({
+  viewId,
+  source,
+  children,
+}: {
+  viewId: string | null
+  source: string
+  children: React.ReactNode
+}) {
+  // eslint-disable-next-line @typescript-eslint/no-shadow
+  const player = useExpoVideoPlayer(source, player => {
+    player.loop = true
+    player.play()
+  })
+
+  // make sure we're playing every time the viewId changes
+  // this means the video is different
+  useEffect(() => {
+    player.play()
+  }, [viewId, player])
+
+  return (
+    <VideoPlayerContext.Provider value={player}>
+      {children}
+    </VideoPlayerContext.Provider>
+  )
+}
+
+export function useVideoPlayer() {
+  const context = useContext(VideoPlayerContext)
+  if (!context) {
+    throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
+  }
+  return context
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
new file mode 100644
index 000000000..329fb1206
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
+  return children
+}
+
+export function useVideoPlayer() {
+  throw new Error('useVideoPlayer must not be used on web')
+}