From fedb94dd70903ba5b653bd7fc76800ddb1f2bc4d Mon Sep 17 00:00:00 2001
From: Hailey <153161762+haileyok@users.noreply.github.com>
Date: Thu, 21 Dec 2023 14:33:46 -0800
Subject: 3rd party embed player (#2217)

* Implement embed player for YT, spotify, and twitch

* fix: handle blur event

* fix: use video dimensions for twitch

* fix: remove hack (?)

* fix: remove origin whitelist (?)

* fix: prevent ads from opening in browser

* fix: handle embeds that don't have a thumb

* feat: handle dark/light mode

* fix: ts warning

* fix: adjust height of no-thumb label

* fix: adjust height of no-thumb label

* fix: remove debug log, set collapsable to false for player view

* fix: fix dimensions "flash"

* chore: remove old youtube link test

* tests: add tests

* fix: thumbless embed position when loading

* fix: remove background from webview

* cleanup embeds (almost)

* more refactoring

- Use separate layers for player and overlay to prevent weird sizing issues
- Be sure the image is not visible under the player
- Clean up some

* cleanup styles

* parse youtube shorts urls

* remove debug

* add soundcloud tracks and sets (playlists)

* move logic into `ExternalLinkEmbed`

* border radius for yt player on native

* fix styling on web

* allow scrolling in webview on android

* remove unnecessary check

* autoplay yt on web

* fix tests after adding autoplay

* move `useNavigation` to top of component

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
---
 .../com/util/post-embeds/ExternalPlayerEmbed.tsx   | 251 +++++++++++++++++++++
 1 file changed, 251 insertions(+)
 create mode 100644 src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx

(limited to 'src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx')

diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
new file mode 100644
index 000000000..580cf363a
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -0,0 +1,251 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  Dimensions,
+  GestureResponderEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {Image} from 'expo-image'
+import {WebView} from 'react-native-webview'
+import YoutubePlayer from 'react-native-youtube-iframe'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
+import {EventStopper} from '../EventStopper'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {isNative} from 'platform/detection'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
+
+interface ShouldStartLoadRequest {
+  url: string
+}
+
+// This renders the overlay when the player is either inactive or loading as a separate layer
+function PlaceholderOverlay({
+  isLoading,
+  isPlayerActive,
+  onPress,
+}: {
+  isLoading: boolean
+  isPlayerActive: boolean
+  onPress: (event: GestureResponderEvent) => void
+}) {
+  // If the player is active and not loading, we don't want to show the overlay.
+  if (isPlayerActive && !isLoading) return null
+
+  return (
+    <View style={[styles.layer, styles.overlayLayer]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Play Video"
+        accessibilityHint=""
+        onPress={onPress}
+        style={[styles.overlayContainer, styles.topRadius]}>
+        {!isPlayerActive ? (
+          <FontAwesomeIcon icon="play" size={42} color="white" />
+        ) : (
+          <ActivityIndicator size="large" color="white" />
+        )}
+      </Pressable>
+    </View>
+  )
+}
+
+// This renders the webview/youtube player as a separate layer
+function Player({
+  height,
+  params,
+  onLoad,
+  isPlayerActive,
+}: {
+  isPlayerActive: boolean
+  params: EmbedPlayerParams
+  height: number
+  onLoad: () => void
+}) {
+  // ensures we only load what's requested
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) => event.url === params.playerUri,
+    [params.playerUri],
+  )
+
+  // Don't show the player until it is active
+  if (!isPlayerActive) return null
+
+  return (
+    <View style={[styles.layer, styles.playerLayer]}>
+      <EventStopper>
+        {isNative && params.type === 'youtube_video' ? (
+          <YoutubePlayer
+            videoId={params.videoId}
+            play
+            height={height}
+            onReady={onLoad}
+            webViewStyle={[styles.webview, styles.topRadius]}
+          />
+        ) : (
+          <View style={{height, width: '100%'}}>
+            <WebView
+              javaScriptEnabled={true}
+              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+              mediaPlaybackRequiresUserAction={false}
+              allowsInlineMediaPlayback
+              bounces={false}
+              allowsFullscreenVideo
+              nestedScrollEnabled
+              source={{uri: params.playerUri}}
+              onLoad={onLoad}
+              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+              style={[styles.webview, styles.topRadius]}
+            />
+          </View>
+        )}
+      </EventStopper>
+    </View>
+  )
+}
+
+// This renders the player area and handles the logic for when to show the player and when to show the overlay
+export function ExternalPlayer({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const navigation = useNavigation<NavigationProp>()
+
+  const [isPlayerActive, setPlayerActive] = React.useState(false)
+  const [isLoading, setIsLoading] = React.useState(true)
+  const [dim, setDim] = React.useState({
+    width: 0,
+    height: 0,
+  })
+
+  const viewRef = React.useRef<View>(null)
+
+  // watch for leaving the viewport due to scrolling
+  React.useEffect(() => {
+    // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
+    // continue playing. We need to watch for the blur event
+    const unsubscribe = navigation.addListener('blur', () => {
+      setPlayerActive(false)
+    })
+
+    const interval = setInterval(() => {
+      viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
+        const window = Dimensions.get('window')
+        const top = pageY
+        const bot = pageY + h
+        const isVisible = isNative
+          ? top >= 0 && bot <= window.height
+          : !(top >= window.height || bot <= 0)
+        if (!isVisible) {
+          setPlayerActive(false)
+        }
+      })
+    }, 1e3)
+    return () => {
+      unsubscribe()
+      clearInterval(interval)
+    }
+  }, [viewRef, navigation])
+
+  // calculate height for the player and the screen size
+  const height = React.useMemo(
+    () =>
+      getPlayerHeight({
+        type: params.type,
+        width: dim.width,
+        hasThumb: !!link.thumb,
+      }),
+    [params.type, dim.width, link.thumb],
+  )
+
+  const onLoad = React.useCallback(() => {
+    setIsLoading(false)
+  }, [])
+
+  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
+    // Prevent this from propagating upward on web
+    event.preventDefault()
+
+    setPlayerActive(true)
+  }, [])
+
+  // measure the layout to set sizing
+  const onLayout = React.useCallback(
+    (event: {nativeEvent: {layout: {width: any; height: any}}}) => {
+      setDim({
+        width: event.nativeEvent.layout.width,
+        height: event.nativeEvent.layout.height,
+      })
+    },
+    [],
+  )
+
+  return (
+    <View
+      ref={viewRef}
+      style={{height}}
+      collapsable={false}
+      onLayout={onLayout}>
+      {link.thumb && (!isPlayerActive || isLoading) && (
+        <Image
+          style={[
+            {
+              width: dim.width,
+              height,
+            },
+            styles.topRadius,
+          ]}
+          source={{uri: link.thumb}}
+          accessibilityIgnoresInvertColors
+        />
+      )}
+
+      <PlaceholderOverlay
+        isLoading={isLoading}
+        isPlayerActive={isPlayerActive}
+        onPress={onPlayPress}
+      />
+      <Player
+        isPlayerActive={isPlayerActive}
+        params={params}
+        height={height}
+        onLoad={onLoad}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  playerLayer: {
+    zIndex: 3,
+  },
+  webview: {
+    backgroundColor: 'transparent',
+  },
+})
-- 
cgit 1.4.1

