about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/posts/FeedItem.tsx1
-rw-r--r--src/view/com/util/List.tsx2
-rw-r--r--src/view/com/util/List.web.tsx22
-rw-r--r--src/view/com/util/post-embeds/ActiveVideoContext.tsx89
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.tsx12
-rw-r--r--src/view/com/util/post-embeds/VideoEmbed.web.tsx190
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.tsx7
-rw-r--r--src/view/com/util/post-embeds/VideoEmbedInner.web.tsx119
-rw-r--r--src/view/com/util/post-embeds/VideoPlayerContext.tsx10
-rw-r--r--src/view/com/util/post-embeds/VideoWebControls.tsx16
-rw-r--r--src/view/com/util/post-embeds/VideoWebControls.web.tsx587
11 files changed, 973 insertions, 82 deletions
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 2c2e2163d..a6e721d43 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -507,7 +507,6 @@ const styles = StyleSheet.create({
     paddingRight: 15,
     // @ts-ignore web only -prf
     cursor: 'pointer',
-    overflow: 'hidden',
   },
   replyLine: {
     width: 2,
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index e1a10e474..9d9b1d802 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -28,8 +28,6 @@ export type ListProps<ItemT> = Omit<
   // Web only prop to contain the scroll to the container rather than the window
   disableFullWindowScroll?: boolean
   sideBorders?: boolean
-  // Web only prop to disable a perf optimization (which would otherwise be on).
-  disableContainStyle?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 5aa699356..5f89cfbbc 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
 
 import {batchedUpdates} from '#/lib/batchedUpdates'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {useScrollHandlers} from '#/lib/ScrollContext'
-import {isSafari} from 'lib/browser'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {addStyle} from 'lib/styles'
+import {addStyle} from '#/lib/styles'
 
 export type ListMethods = any // TODO: Better types.
 export type ListProps<ItemT> = Omit<
@@ -26,8 +25,6 @@ export type ListProps<ItemT> = Omit<
   // Web only prop to contain the scroll to the container rather than the window
   disableFullWindowScroll?: boolean
   sideBorders?: boolean
-  // Web only prop to disable a perf optimization (which would otherwise be on).
-  disableContainStyle?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
@@ -60,7 +57,6 @@ function ListImpl<ItemT>(
     extraData,
     style,
     sideBorders = true,
-    disableContainStyle,
     ...props
   }: ListProps<ItemT>,
   ref: React.Ref<ListMethods>,
@@ -364,7 +360,6 @@ function ListImpl<ItemT>(
                   renderItem={renderItem}
                   extraData={extraData}
                   onItemSeen={onItemSeen}
-                  disableContainStyle={disableContainStyle}
                 />
               )
             })}
@@ -442,7 +437,6 @@ let Row = function RowImpl<ItemT>({
   renderItem,
   extraData: _unused,
   onItemSeen,
-  disableContainStyle,
 }: {
   item: ItemT
   index: number
@@ -452,7 +446,6 @@ let Row = function RowImpl<ItemT>({
     | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
   extraData: any
   onItemSeen: ((item: any) => void) | undefined
-  disableContainStyle?: boolean
 }): React.ReactNode {
   const rowRef = React.useRef(null)
   const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
@@ -501,11 +494,8 @@ let Row = function RowImpl<ItemT>({
     return null
   }
 
-  const shouldDisableContainStyle = disableContainStyle || isSafari
   return (
-    <View
-      style={shouldDisableContainStyle ? undefined : styles.contain}
-      ref={rowRef}>
+    <View ref={rowRef}>
       {renderItem({item, index, separators: null as any})}
     </View>
   )
@@ -576,10 +566,6 @@ const styles = StyleSheet.create({
     marginLeft: 'auto',
     marginRight: 'auto',
   },
-  contain: {
-    // @ts-ignore web only
-    contain: 'layout paint',
-  },
   minHeightViewport: {
     // @ts-ignore web only
     minHeight: '100vh',
diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
index 6804436a7..d18dfc090 100644
--- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
@@ -1,37 +1,103 @@
-import React, {useCallback, useId, useMemo, useState} from 'react'
+import React, {
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import {useWindowDimensions} from 'react-native'
 
+import {isNative} from '#/platform/detection'
 import {VideoPlayerProvider} from './VideoPlayerContext'
 
 const ActiveVideoContext = React.createContext<{
   activeViewId: string | null
   setActiveView: (viewId: string, src: string) => void
+  sendViewPosition: (viewId: string, y: number) => void
 } | null>(null)
 
 export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
   const [activeViewId, setActiveViewId] = useState<string | null>(null)
+  const activeViewLocationRef = useRef(Infinity)
   const [source, setSource] = useState<string | null>(null)
+  const {height: windowHeight} = useWindowDimensions()
+
+  // minimising re-renders by using refs
+  const manuallySetRef = useRef(false)
+  const activeViewIdRef = useRef(activeViewId)
+  useEffect(() => {
+    activeViewIdRef.current = activeViewId
+  }, [activeViewId])
+
+  const setActiveView = useCallback(
+    (viewId: string, src: string) => {
+      setActiveViewId(viewId)
+      setSource(src)
+      manuallySetRef.current = true
+      // we don't know the exact position, but it's definitely on screen
+      // so just guess that it's in the middle. Any value is fine
+      // so long as it's not offscreen
+      activeViewLocationRef.current = windowHeight / 2
+    },
+    [windowHeight],
+  )
+
+  const sendViewPosition = useCallback(
+    (viewId: string, y: number) => {
+      if (isNative) return
+
+      if (viewId === activeViewIdRef.current) {
+        activeViewLocationRef.current = y
+      } else {
+        if (
+          distanceToIdealPosition(y) <
+          distanceToIdealPosition(activeViewLocationRef.current)
+        ) {
+          // if the old view was manually set, only usurp if the old view is offscreen
+          if (
+            manuallySetRef.current &&
+            withinViewport(activeViewLocationRef.current)
+          ) {
+            return
+          }
+
+          setActiveViewId(viewId)
+          activeViewLocationRef.current = y
+          manuallySetRef.current = false
+        }
+      }
+
+      function distanceToIdealPosition(yPos: number) {
+        return Math.abs(yPos - windowHeight / 2.5)
+      }
+
+      function withinViewport(yPos: number) {
+        return yPos > 0 && yPos < windowHeight
+      }
+    },
+    [windowHeight],
+  )
 
   const value = useMemo(
     () => ({
       activeViewId,
-      setActiveView: (viewId: string, src: string) => {
-        setActiveViewId(viewId)
-        setSource(src)
-      },
+      setActiveView,
+      sendViewPosition,
     }),
-    [activeViewId],
+    [activeViewId, setActiveView, sendViewPosition],
   )
 
   return (
     <ActiveVideoContext.Provider value={value}>
-      <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
+      <VideoPlayerProvider source={source ?? ''}>
         {children}
       </VideoPlayerProvider>
     </ActiveVideoContext.Provider>
   )
 }
 
-export function useActiveVideoView() {
+export function useActiveVideoView({source}: {source: string}) {
   const context = React.useContext(ActiveVideoContext)
   if (!context) {
     throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
@@ -41,7 +107,12 @@ export function useActiveVideoView() {
   return {
     active: context.activeViewId === id,
     setActive: useCallback(
-      (source: string) => context.setActiveView(id, source),
+      () => context.setActiveView(id, source),
+      [context, id, source],
+    ),
+    currentActiveView: context.activeViewId,
+    sendPosition: useCallback(
+      (y: number) => context.sendViewPosition(id, y),
       [context, id],
     ),
   }
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 5e5293a55..429312d9e 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -11,10 +11,10 @@ import {VideoEmbedInner} from './VideoEmbedInner'
 
 export function VideoEmbed({source}: {source: string}) {
   const t = useTheme()
-  const {active, setActive} = useActiveVideoView()
+  const {active, setActive} = useActiveVideoView({source})
   const {_} = useLingui()
 
-  const onPress = useCallback(() => setActive(source), [setActive, source])
+  const onPress = useCallback(() => setActive(), [setActive])
 
   return (
     <View
@@ -27,7 +27,13 @@ export function VideoEmbed({source}: {source: string}) {
         a.my_xs,
       ]}>
       {active ? (
-        <VideoEmbedInner source={source} />
+        <VideoEmbedInner
+          source={source}
+          // web only
+          active={active}
+          setActive={setActive}
+          onScreen={true}
+        />
       ) : (
         <Button
           style={[a.flex_1, t.atoms.bg_contrast_25]}
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>
+  )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
index ef0678709..9b1fd54fb 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
@@ -13,7 +13,12 @@ import {atoms as a} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useVideoPlayer} from './VideoPlayerContext'
 
-export const VideoEmbedInner = ({}: {source: string}) => {
+export function VideoEmbedInner({}: {
+  source: string
+  active: boolean
+  setActive: () => void
+  onScreen: boolean
+}) {
   const player = useVideoPlayer()
   const aref = useAnimatedRef<Animated.View>()
   const {height: windowHeight} = useWindowDimensions()
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
index cb02743c6..f5f47db50 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
@@ -1,52 +1,93 @@
-import React, {useEffect, useRef} from 'react'
+import React, {useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
 import Hls from 'hls.js'
 
 import {atoms as a} from '#/alf'
+import {Controls} from './VideoWebControls'
 
-export const VideoEmbedInner = ({source}: {source: string}) => {
+export function VideoEmbedInner({
+  source,
+  active,
+  setActive,
+  onScreen,
+}: {
+  source: string
+  active: boolean
+  setActive: () => void
+  onScreen: boolean
+}) {
+  const containerRef = useRef<HTMLDivElement>(null)
   const ref = useRef<HTMLVideoElement>(null)
+  const [focused, setFocused] = useState(false)
+  const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
+
+  const hlsRef = useRef<Hls | undefined>(undefined)
 
-  // 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
+    if (!ref.current) return
+    if (!Hls.isSupported()) throw new HLSUnsupportedError()
+
+    const hls = new Hls({capLevelToPlayerSize: true})
+    hlsRef.current = hls
+
+    hls.attachMedia(ref.current)
+    hls.loadSource(source)
+
+    // initial value, later on it's managed by Controls
+    hls.autoLevelCapping = 0
+
+    hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
+      if (data.subtitleTracks.length > 0) {
+        setHasSubtitleTrack(true)
       }
+    })
+
+    return () => {
+      hlsRef.current = undefined
+      hls.detachMedia()
+      hls.destroy()
     }
   }, [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 (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_sm,
+        // TODO: get from embed metadata
+        // max should be 1 / 1
+        {aspectRatio: 16 / 9},
+        a.overflow_hidden,
+      ]}>
+      <div
+        ref={containerRef}
+        style={{width: '100%', height: '100%', display: 'flex'}}>
+        <video
+          ref={ref}
+          style={{width: '100%', height: '100%', objectFit: 'contain'}}
+          playsInline
+          preload="none"
+          loop
+          muted={!focused}
+        />
+        <Controls
+          videoRef={ref}
+          hlsRef={hlsRef}
+          active={active}
+          setActive={setActive}
+          focused={focused}
+          setFocused={setFocused}
+          onScreen={onScreen}
+          fullscreenRef={containerRef}
+          hasSubtitleTrack={hasSubtitleTrack}
+        />
+      </div>
+    </View>
+  )
+}
 
-  return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
+export class HLSUnsupportedError extends Error {
+  constructor() {
+    super('HLS is not supported')
+  }
 }
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
index bc5d9d370..473343ca4 100644
--- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -1,15 +1,13 @@
-import React, {useContext, useEffect} from 'react'
+import React, {useContext} 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
 }) {
@@ -19,12 +17,6 @@ export function VideoPlayerProvider({
     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}
diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoWebControls.tsx
new file mode 100644
index 000000000..11e0867e4
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoWebControls.tsx
@@ -0,0 +1,16 @@
+import React from 'react'
+import type Hls from 'hls.js'
+
+export function Controls({}: {
+  videoRef: React.RefObject<HTMLVideoElement>
+  hlsRef: React.RefObject<Hls | undefined>
+  active: boolean
+  setActive: () => void
+  focused: boolean
+  setFocused: (focused: boolean) => void
+  onScreen: boolean
+  fullscreenRef: React.RefObject<HTMLDivElement>
+  hasSubtitleTrack: boolean
+}): React.ReactElement {
+  throw new Error('Web-only component')
+}
diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoWebControls.web.tsx
new file mode 100644
index 000000000..2843664be
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoWebControls.web.tsx
@@ -0,0 +1,587 @@
+import React, {
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  useSyncExternalStore,
+} from 'react'
+import {Pressable, View} from 'react-native'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type Hls from 'hls.js'
+
+import {isIPhoneWeb} from '#/platform/detection'
+import {
+  useAutoplayDisabled,
+  useSetSubtitlesEnabled,
+  useSubtitlesEnabled,
+} from '#/state/preferences'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button} from '#/components/Button'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {
+  ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
+  ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
+} from '#/components/icons/ArrowsDiagonal'
+import {
+  CC_Filled_Corner0_Rounded as CCActiveIcon,
+  CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
+} from '#/components/icons/CC'
+import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
+import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
+import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function Controls({
+  videoRef,
+  hlsRef,
+  active,
+  setActive,
+  focused,
+  setFocused,
+  onScreen,
+  fullscreenRef,
+  hasSubtitleTrack,
+}: {
+  videoRef: React.RefObject<HTMLVideoElement>
+  hlsRef: React.RefObject<Hls | undefined>
+  active: boolean
+  setActive: () => void
+  focused: boolean
+  setFocused: (focused: boolean) => void
+  onScreen: boolean
+  fullscreenRef: React.RefObject<HTMLDivElement>
+  hasSubtitleTrack: boolean
+}) {
+  const {
+    play,
+    pause,
+    playing,
+    muted,
+    toggleMute,
+    togglePlayPause,
+    currentTime,
+    duration,
+    buffering,
+    error,
+    canPlay,
+  } = useVideoUtils(videoRef)
+  const t = useTheme()
+  const {_} = useLingui()
+  const subtitlesEnabled = useSubtitlesEnabled()
+  const setSubtitlesEnabled = useSetSubtitlesEnabled()
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
+  const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
+
+  const onKeyDown = useCallback(() => {
+    setInteractingViaKeypress(true)
+  }, [])
+
+  useEffect(() => {
+    if (interactingViaKeypress) {
+      document.addEventListener('click', () => setInteractingViaKeypress(false))
+      return () => {
+        document.removeEventListener('click', () =>
+          setInteractingViaKeypress(false),
+        )
+      }
+    }
+  }, [interactingViaKeypress])
+
+  // pause + unfocus when another video is active
+  useEffect(() => {
+    if (!active) {
+      pause()
+      setFocused(false)
+    }
+  }, [active, pause, setFocused])
+
+  // autoplay/pause based on visibility
+  const autoplayDisabled = useAutoplayDisabled()
+  useEffect(() => {
+    if (active && !autoplayDisabled) {
+      if (onScreen) {
+        play()
+      } else {
+        pause()
+      }
+    }
+  }, [onScreen, pause, active, play, autoplayDisabled])
+
+  // use minimal quality when not focused
+  useEffect(() => {
+    if (!hlsRef.current) return
+    if (focused) {
+      // auto decide quality based on network conditions
+      hlsRef.current.autoLevelCapping = -1
+    } else {
+      hlsRef.current.autoLevelCapping = 0
+    }
+  }, [hlsRef, focused])
+
+  useEffect(() => {
+    if (!hlsRef.current) return
+    if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
+      hlsRef.current.subtitleTrack = 0
+    } else {
+      hlsRef.current.subtitleTrack = -1
+    }
+  }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
+
+  // clicking on any button should focus the player, if it's not already focused
+  const drawFocus = useCallback(() => {
+    if (!active) {
+      setActive()
+    }
+    setFocused(true)
+  }, [active, setActive, setFocused])
+
+  const onPressEmptySpace = useCallback(() => {
+    if (!focused) {
+      drawFocus()
+    } else {
+      togglePlayPause()
+    }
+  }, [togglePlayPause, drawFocus, focused])
+
+  const onPressPlayPause = useCallback(() => {
+    drawFocus()
+    togglePlayPause()
+  }, [drawFocus, togglePlayPause])
+
+  const onPressSubtitles = useCallback(() => {
+    drawFocus()
+    setSubtitlesEnabled(!subtitlesEnabled)
+  }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
+
+  const onPressMute = useCallback(() => {
+    drawFocus()
+    toggleMute()
+  }, [drawFocus, toggleMute])
+
+  const onPressFullscreen = useCallback(() => {
+    drawFocus()
+    toggleFullscreen()
+  }, [drawFocus, toggleFullscreen])
+
+  const showControls =
+    (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
+
+  return (
+    <div
+      style={{
+        position: 'absolute',
+        inset: 0,
+        overflow: 'hidden',
+        display: 'flex',
+        flexDirection: 'column',
+      }}
+      onClick={evt => {
+        evt.stopPropagation()
+        setInteractingViaKeypress(false)
+      }}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onKeyDown={onKeyDown}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityHint={_(
+          focused
+            ? msg`Unmute video`
+            : playing
+            ? msg`Pause video`
+            : msg`Play video`,
+        )}
+        style={a.flex_1}
+        onPress={onPressEmptySpace}
+      />
+      <View
+        style={[
+          a.flex_shrink_0,
+          a.w_full,
+          a.px_sm,
+          a.pt_sm,
+          a.pb_md,
+          a.gap_md,
+          a.flex_row,
+          a.align_center,
+          web({
+            background:
+              'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
+          }),
+          showControls ? {opacity: 1} : {opacity: 0},
+        ]}>
+        <Button
+          label={_(playing ? msg`Pause` : msg`Play`)}
+          onPress={onPressPlayPause}
+          {...btnProps}>
+          {playing ? (
+            <PauseIcon fill={t.palette.white} width={20} />
+          ) : (
+            <PlayIcon fill={t.palette.white} width={20} />
+          )}
+        </Button>
+        <View style={a.flex_1} />
+        <Text style={{color: t.palette.white}}>
+          {formatTime(currentTime)} / {formatTime(duration)}
+        </Text>
+        {hasSubtitleTrack && (
+          <Button
+            label={_(
+              subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
+            )}
+            onPress={onPressSubtitles}
+            {...btnProps}>
+            {subtitlesEnabled ? (
+              <CCActiveIcon fill={t.palette.white} width={20} />
+            ) : (
+              <CCInactiveIcon fill={t.palette.white} width={20} />
+            )}
+          </Button>
+        )}
+        <Button
+          label={_(muted ? msg`Unmute` : msg`Mute`)}
+          onPress={onPressMute}
+          {...btnProps}>
+          {muted ? (
+            <MuteIcon fill={t.palette.white} width={20} />
+          ) : (
+            <UnmuteIcon fill={t.palette.white} width={20} />
+          )}
+        </Button>
+        {!isIPhoneWeb && (
+          <Button
+            label={_(muted ? msg`Unmute` : msg`Mute`)}
+            onPress={onPressFullscreen}
+            {...btnProps}>
+            {isFullscreen ? (
+              <ArrowsInIcon fill={t.palette.white} width={20} />
+            ) : (
+              <ArrowsOutIcon fill={t.palette.white} width={20} />
+            )}
+          </Button>
+        )}
+      </View>
+      {(showControls || !focused) && (
+        <Animated.View
+          entering={FadeIn.duration(200)}
+          exiting={FadeOut.duration(200)}
+          style={[
+            a.absolute,
+            {
+              height: 5,
+              bottom: 0,
+              left: 0,
+              right: 0,
+              backgroundColor: 'rgba(255,255,255,0.4)',
+            },
+          ]}>
+          {duration > 0 && (
+            <View
+              style={[
+                a.h_full,
+                a.mr_auto,
+                {
+                  backgroundColor: t.palette.white,
+                  width: `${(currentTime / duration) * 100}%`,
+                  opacity: 0.8,
+                },
+              ]}
+            />
+          )}
+        </Animated.View>
+      )}
+      {(buffering || error) && (
+        <Animated.View
+          pointerEvents="none"
+          entering={FadeIn.delay(1000).duration(200)}
+          exiting={FadeOut.duration(200)}
+          style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+          {buffering && <Loader fill={t.palette.white} size="lg" />}
+          {error && (
+            <Text style={{color: t.palette.white}}>
+              <Trans>An error occurred</Trans>
+            </Text>
+          )}
+        </Animated.View>
+      )}
+    </div>
+  )
+}
+
+const btnProps = {
+  variant: 'ghost',
+  shape: 'round',
+  size: 'medium',
+  style: a.p_2xs,
+  hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
+} as const
+
+function formatTime(time: number) {
+  if (isNaN(time)) {
+    return '--'
+  }
+
+  time = Math.round(time)
+
+  const minutes = Math.floor(time / 60)
+  const seconds = String(time % 60).padStart(2, '0')
+
+  return `${minutes}:${seconds}`
+}
+
+function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
+  const [playing, setPlaying] = useState(false)
+  const [muted, setMuted] = useState(true)
+  const [currentTime, setCurrentTime] = useState(0)
+  const [duration, setDuration] = useState(0)
+  const [buffering, setBuffering] = useState(false)
+  const [error, setError] = useState(false)
+  const [canPlay, setCanPlay] = useState(false)
+  const playWhenReadyRef = useRef(false)
+
+  useEffect(() => {
+    if (!ref.current) return
+
+    let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
+
+    function round(num: number) {
+      return Math.round(num * 100) / 100
+    }
+
+    // Initial values
+    setCurrentTime(round(ref.current.currentTime) || 0)
+    setDuration(round(ref.current.duration) || 0)
+    setMuted(ref.current.muted)
+    setPlaying(!ref.current.paused)
+
+    const handleTimeUpdate = () => {
+      if (!ref.current) return
+      setCurrentTime(round(ref.current.currentTime) || 0)
+    }
+
+    const handleDurationChange = () => {
+      if (!ref.current) return
+      setDuration(round(ref.current.duration) || 0)
+    }
+
+    const handlePlay = () => {
+      setPlaying(true)
+    }
+
+    const handlePause = () => {
+      setPlaying(false)
+    }
+
+    const handleVolumeChange = () => {
+      if (!ref.current) return
+      setMuted(ref.current.muted)
+    }
+
+    const handleError = () => {
+      setError(true)
+    }
+
+    const handleCanPlay = () => {
+      setBuffering(false)
+      setCanPlay(true)
+
+      if (!ref.current) return
+      if (playWhenReadyRef.current) {
+        ref.current.play()
+        playWhenReadyRef.current = false
+      }
+    }
+
+    const handleCanPlayThrough = () => {
+      setBuffering(false)
+    }
+
+    const handleWaiting = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      bufferingTimeout = setTimeout(() => {
+        setBuffering(true)
+      }, 200) // Delay to avoid frequent buffering state changes
+    }
+
+    const handlePlaying = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      setBuffering(false)
+      setError(false)
+    }
+
+    const handleSeeking = () => {
+      setBuffering(true)
+    }
+
+    const handleSeeked = () => {
+      setBuffering(false)
+    }
+
+    const handleStalled = () => {
+      if (bufferingTimeout) clearTimeout(bufferingTimeout)
+      bufferingTimeout = setTimeout(() => {
+        setBuffering(true)
+      }, 200) // Delay to avoid frequent buffering state changes
+    }
+
+    const handleEnded = () => {
+      setPlaying(false)
+      setBuffering(false)
+      setError(false)
+    }
+
+    const abortController = new AbortController()
+
+    ref.current.addEventListener('timeupdate', handleTimeUpdate, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('durationchange', handleDurationChange, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('play', handlePlay, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('pause', handlePause, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('volumechange', handleVolumeChange, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('error', handleError, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('canplay', handleCanPlay, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('waiting', handleWaiting, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('playing', handlePlaying, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('seeking', handleSeeking, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('seeked', handleSeeked, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('stalled', handleStalled, {
+      signal: abortController.signal,
+    })
+    ref.current.addEventListener('ended', handleEnded, {
+      signal: abortController.signal,
+    })
+
+    return () => {
+      abortController.abort()
+      clearTimeout(bufferingTimeout)
+    }
+  }, [ref])
+
+  const play = useCallback(() => {
+    if (!ref.current) return
+
+    if (ref.current.ended) {
+      ref.current.currentTime = 0
+    }
+
+    if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
+      playWhenReadyRef.current = true
+    } else {
+      const promise = ref.current.play()
+      if (promise !== undefined) {
+        promise.catch(err => {
+          console.error('Error playing video:', err)
+        })
+      }
+    }
+  }, [ref])
+
+  const pause = useCallback(() => {
+    if (!ref.current) return
+
+    ref.current.pause()
+    playWhenReadyRef.current = false
+  }, [ref])
+
+  const togglePlayPause = useCallback(() => {
+    if (!ref.current) return
+
+    if (ref.current.paused) {
+      play()
+    } else {
+      pause()
+    }
+  }, [ref, play, pause])
+
+  const mute = useCallback(() => {
+    if (!ref.current) return
+
+    ref.current.muted = true
+  }, [ref])
+
+  const unmute = useCallback(() => {
+    if (!ref.current) return
+
+    ref.current.muted = false
+  }, [ref])
+
+  const toggleMute = useCallback(() => {
+    if (!ref.current) return
+
+    ref.current.muted = !ref.current.muted
+  }, [ref])
+
+  return {
+    play,
+    pause,
+    togglePlayPause,
+    duration,
+    currentTime,
+    playing,
+    muted,
+    mute,
+    unmute,
+    toggleMute,
+    buffering,
+    error,
+    canPlay,
+  }
+}
+
+function fullscreenSubscribe(onChange: () => void) {
+  document.addEventListener('fullscreenchange', onChange)
+  return () => document.removeEventListener('fullscreenchange', onChange)
+}
+
+function useFullscreen(ref: React.RefObject<HTMLElement>) {
+  const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
+    Boolean(document.fullscreenElement),
+  )
+
+  const toggleFullscreen = useCallback(() => {
+    if (isFullscreen) {
+      document.exitFullscreen()
+    } else {
+      if (!ref.current) return
+      ref.current.requestFullscreen()
+    }
+  }, [isFullscreen, ref])
+
+  return [isFullscreen, toggleFullscreen] as const
+}