about summary refs log tree commit diff
path: root/src/screens/VideoFeed/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/VideoFeed/components')
-rw-r--r--src/screens/VideoFeed/components/Header.tsx180
-rw-r--r--src/screens/VideoFeed/components/Scrubber.tsx265
2 files changed, 445 insertions, 0 deletions
diff --git a/src/screens/VideoFeed/components/Header.tsx b/src/screens/VideoFeed/components/Header.tsx
new file mode 100644
index 000000000..66c932119
--- /dev/null
+++ b/src/screens/VideoFeed/components/Header.tsx
@@ -0,0 +1,180 @@
+import {useCallback} from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {HITSLOP_30} from '#/lib/constants'
+import {NavigationProp} from '#/lib/routes/types'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useFeedSourceInfoQuery} from '#/state/queries/feed'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonProps} from '#/components/Button'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
+import * as Layout from '#/components/Layout'
+import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const'
+import {Text} from '#/components/Typography'
+
+export function HeaderPlaceholder() {
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+      <View
+        style={[
+          a.rounded_sm,
+          {
+            width: 36,
+            height: 36,
+            backgroundColor: 'white',
+            opacity: 0.8,
+          },
+        ]}
+      />
+
+      <View style={[a.flex_1, a.gap_xs]}>
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            {
+              backgroundColor: 'white',
+              height: 14,
+              width: 80,
+              opacity: 0.8,
+            },
+          ]}
+        />
+        <View
+          style={[
+            a.w_full,
+            a.rounded_xs,
+            {
+              backgroundColor: 'white',
+              height: 10,
+              width: 140,
+              opacity: 0.6,
+            },
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
+
+export function Header({
+  sourceContext,
+}: {
+  sourceContext: VideoFeedSourceContext
+}) {
+  let content = null
+  switch (sourceContext.type) {
+    case 'feedgen': {
+      content = <FeedHeader sourceContext={sourceContext} />
+      break
+    }
+    case 'author':
+    // TODO
+    default: {
+      break
+    }
+  }
+
+  return (
+    <Layout.Header.Outer noBottomBorder>
+      <BackButton />
+      <Layout.Header.Content align="left">{content}</Layout.Header.Content>
+    </Layout.Header.Outer>
+  )
+}
+
+export function FeedHeader({
+  sourceContext,
+}: {
+  sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}>
+}) {
+  const {gtMobile} = useBreakpoints()
+
+  const {
+    data: info,
+    isLoading,
+    error,
+  } = useFeedSourceInfoQuery({uri: sourceContext.uri})
+
+  if (sourceContext.sourceInterstitial !== undefined) {
+    // For now, don't show the header if coming from an interstitial.
+    return null
+  }
+
+  if (isLoading) {
+    return <HeaderPlaceholder />
+  } else if (error || !info) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+      {info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />}
+
+      <View style={[a.flex_1]}>
+        <Text
+          style={[
+            a.text_md,
+            a.font_heavy,
+            a.leading_tight,
+            gtMobile && a.text_lg,
+          ]}
+          numberOfLines={2}>
+          {info.displayName}
+        </Text>
+        <View style={[a.flex_row, {gap: 6}]}>
+          <Text
+            style={[a.flex_shrink, a.text_sm, a.leading_snug]}
+            numberOfLines={1}>
+            {sanitizeHandle(info.creatorHandle, '@')}
+          </Text>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+// TODO: This customization should be a part of the layout component
+export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = useCallback(
+    (evt: GestureResponderEvent) => {
+      onPress?.(evt)
+      if (evt.defaultPrevented) return
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    },
+    [onPress, navigation],
+  )
+
+  return (
+    <Layout.Header.Slot>
+      <Button
+        label={_(msg`Go back`)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="round"
+        onPress={onPressBack}
+        hitSlop={HITSLOP_30}
+        style={[
+          {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
+          a.bg_transparent,
+          style,
+        ]}
+        {...props}>
+        <ArrowLeft size="lg" fill="white" />
+      </Button>
+    </Layout.Header.Slot>
+  )
+}
diff --git a/src/screens/VideoFeed/components/Scrubber.tsx b/src/screens/VideoFeed/components/Scrubber.tsx
new file mode 100644
index 000000000..ef3190526
--- /dev/null
+++ b/src/screens/VideoFeed/components/Scrubber.tsx
@@ -0,0 +1,265 @@
+import {useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  NativeGesture,
+} from 'react-native-gesture-handler'
+import Animated, {
+  interpolate,
+  runOnJS,
+  runOnUI,
+  SharedValue,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {
+  useSafeAreaFrame,
+  useSafeAreaInsets,
+} from 'react-native-safe-area-context'
+import {useEventListener} from 'expo'
+import {VideoPlayer} from 'expo-video'
+
+import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
+import {tokens} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+
+// magic number that is roughly the min height of the write reply button
+// we inset the video by this amount
+export const VIDEO_PLAYER_BOTTOM_INSET = 57
+
+export function Scrubber({
+  active,
+  player,
+  seekingAnimationSV,
+  scrollGesture,
+  children,
+}: {
+  active: boolean
+  player?: VideoPlayer
+  seekingAnimationSV: SharedValue<number>
+  scrollGesture: NativeGesture
+  children?: React.ReactNode
+}) {
+  const {width: screenWidth} = useSafeAreaFrame()
+  const insets = useSafeAreaInsets()
+  const currentTimeSV = useSharedValue(0)
+  const durationSV = useSharedValue(0)
+  const [currentSeekTime, setCurrentSeekTime] = useState(0)
+  const [duration, setDuration] = useState(0)
+
+  const updateTime = (currentTime: number, duration: number) => {
+    'worklet'
+    currentTimeSV.set(currentTime)
+    if (duration !== 0) {
+      durationSV.set(duration)
+    }
+  }
+
+  const isSeekingSV = useSharedValue(false)
+  const seekProgressSV = useSharedValue(0)
+
+  useAnimatedReaction(
+    () => Math.round(seekProgressSV.get()),
+    (progress, prevProgress) => {
+      if (progress !== prevProgress) {
+        runOnJS(setCurrentSeekTime)(progress)
+      }
+    },
+  )
+
+  const seekBy = useCallback(
+    (time: number) => {
+      player?.seekBy(time)
+
+      setTimeout(() => {
+        runOnUI(() => {
+          'worklet'
+          isSeekingSV.set(false)
+          seekingAnimationSV.set(withTiming(0, {duration: 500}))
+        })()
+      }, 50)
+    },
+    [player, isSeekingSV, seekingAnimationSV],
+  )
+
+  const scrubPanGesture = useMemo(() => {
+    return Gesture.Pan()
+      .blocksExternalGesture(scrollGesture)
+      .activeOffsetX([-10, 10])
+      .failOffsetY([-10, 10])
+      .onStart(() => {
+        'worklet'
+        seekProgressSV.set(currentTimeSV.get())
+        isSeekingSV.set(true)
+        seekingAnimationSV.set(withTiming(1, {duration: 500}))
+      })
+      .onUpdate(evt => {
+        'worklet'
+        const progress = evt.x / screenWidth
+        seekProgressSV.set(
+          clamp(progress * durationSV.get(), 0, durationSV.get()),
+        )
+      })
+      .onEnd(evt => {
+        'worklet'
+        isSeekingSV.get()
+
+        const progress = evt.x / screenWidth
+        const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
+
+        // optimisically set the progress bar
+        seekProgressSV.set(newTime)
+
+        // it's seek by, so offset by the current time
+        // seekBy sets isSeekingSV back to false, so no need to do that here
+        runOnJS(seekBy)(newTime - currentTimeSV.get())
+      })
+  }, [
+    scrollGesture,
+    seekingAnimationSV,
+    seekBy,
+    screenWidth,
+    currentTimeSV,
+    durationSV,
+    isSeekingSV,
+    seekProgressSV,
+  ])
+
+  const timeStyle = useAnimatedStyle(() => {
+    return {
+      display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
+      opacity: seekingAnimationSV.get(),
+    }
+  })
+
+  const barStyle = useAnimatedStyle(() => {
+    const currentTime = isSeekingSV.get()
+      ? seekProgressSV.get()
+      : currentTimeSV.get()
+    const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
+    const isSeeking = seekingAnimationSV.get()
+    return {
+      height: isSeeking * 3 + 1,
+      opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
+      width: `${progress * 100}%`,
+    }
+  })
+  const trackStyle = useAnimatedStyle(() => {
+    return {
+      height: seekingAnimationSV.get() * 3 + 1,
+    }
+  })
+  const childrenStyle = useAnimatedStyle(() => {
+    return {
+      opacity: 1 - seekingAnimationSV.get(),
+    }
+  })
+
+  return (
+    <>
+      {player && active && (
+        <PlayerListener
+          player={player}
+          setDuration={setDuration}
+          updateTime={updateTime}
+        />
+      )}
+      <Animated.View
+        style={[
+          a.absolute,
+          {
+            left: 0,
+            right: 0,
+            bottom: insets.bottom + 80,
+          },
+          timeStyle,
+        ]}
+        pointerEvents="none">
+        <Text style={[a.text_center, a.font_bold]}>
+          <Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
+            {formatTime(currentSeekTime)}
+          </Text>
+          <Text style={[a.text_2xl, {opacity: 0.8}]}>{'  /  '}</Text>
+          <Text
+            style={[
+              a.text_5xl,
+              {opacity: 0.8},
+              {fontVariant: ['tabular-nums']},
+            ]}>
+            {formatTime(duration)}
+          </Text>
+        </Text>
+      </Animated.View>
+
+      <GestureDetector gesture={scrubPanGesture}>
+        <View
+          style={[
+            a.relative,
+            a.w_full,
+            a.justify_end,
+            {
+              paddingBottom: insets.bottom,
+              minHeight:
+                // bottom padding
+                insets.bottom +
+                // scrubber height
+                tokens.space.lg +
+                // write reply height
+                VIDEO_PLAYER_BOTTOM_INSET,
+            },
+            a.z_10,
+          ]}>
+          <View style={[a.w_full, a.relative]}>
+            <Animated.View
+              style={[
+                a.w_full,
+                {backgroundColor: 'white', opacity: 0.2},
+                trackStyle,
+              ]}
+            />
+            <Animated.View
+              style={[
+                a.absolute,
+                {top: 0, left: 0, backgroundColor: 'white'},
+                barStyle,
+              ]}
+            />
+          </View>
+          <Animated.View
+            style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
+            {children}
+          </Animated.View>
+        </View>
+      </GestureDetector>
+    </>
+  )
+}
+
+function PlayerListener({
+  player,
+  setDuration,
+  updateTime,
+}: {
+  player: VideoPlayer
+  setDuration: (duration: number) => void
+  updateTime: (currentTime: number, duration: number) => void
+}) {
+  useEventListener(player, 'timeUpdate', evt => {
+    const duration = player.duration
+    if (duration !== 0) {
+      setDuration(Math.round(duration))
+    }
+    runOnUI(updateTime)(evt.currentTime, duration)
+  })
+
+  return null
+}
+
+function clamp(num: number, min: number, max: number) {
+  'worklet'
+  return Math.min(Math.max(num, min), max)
+}