about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/components/icons/ArrowsDiagonal.tsx17
-rw-r--r--src/components/icons/CC.tsx9
-rw-r--r--src/components/icons/Pause.tsx17
-rw-r--r--src/components/icons/Play.tsx8
-rw-r--r--src/platform/detection.ts1
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx3
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/index.tsx6
-rw-r--r--src/state/preferences/subtitles.tsx42
-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
20 files changed, 1074 insertions, 86 deletions
diff --git a/src/components/icons/ArrowsDiagonal.tsx b/src/components/icons/ArrowsDiagonal.tsx
new file mode 100644
index 000000000..3f9ae40e0
--- /dev/null
+++ b/src/components/icons/ArrowsDiagonal.tsx
@@ -0,0 +1,17 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
+})
+
+export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
+})
+
+export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
+})
+
+export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/CC.tsx b/src/components/icons/CC.tsx
new file mode 100644
index 000000000..da2e7c5db
--- /dev/null
+++ b/src/components/icons/CC.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
+})
+
+export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
+})
diff --git a/src/components/icons/Pause.tsx b/src/components/icons/Pause.tsx
new file mode 100644
index 000000000..927f285a0
--- /dev/null
+++ b/src/components/icons/Pause.tsx
@@ -0,0 +1,17 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
+})
+
+export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
+})
+
+export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
+})
+
+export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
+  path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
+})
diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx
index acf421d57..176b24f28 100644
--- a/src/components/icons/Play.tsx
+++ b/src/components/icons/Play.tsx
@@ -1,5 +1,13 @@
 import {createSinglePathSVG} from './TEMPLATE'
 
+export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
+})
+
+export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
+  path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
+})
+
 export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
   path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
 })
diff --git a/src/platform/detection.ts b/src/platform/detection.ts
index f00df0ee4..c62ae71aa 100644
--- a/src/platform/detection.ts
+++ b/src/platform/detection.ts
@@ -14,6 +14,7 @@ export const isMobileWeb =
   isWeb &&
   // @ts-ignore we know window exists -prf
   global.window.matchMedia(isMobileWebMediaQuery)?.matches
+export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
 
 export const deviceLocales = dedupArray(
   getLocales?.()
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 11b951e99..c0e78e978 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -387,9 +387,6 @@ export function MessagesList({
           renderItem={renderItem}
           keyExtractor={keyExtractor}
           disableFullWindowScroll={true}
-          // Prevents wrong position in Firefox when sending a message
-          // as well as scroll getting stuck on Chome when scrolling upwards.
-          disableContainStyle={true}
           disableVirtualization={true}
           style={animatedListStyle}
           // The extra two items account for the header and the footer components
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 0b652a1f0..331a111a2 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -91,6 +91,7 @@ const schema = z.object({
   disableAutoplay: z.boolean().optional(),
   kawaii: z.boolean().optional(),
   hasCheckedForStarterPack: z.boolean().optional(),
+  subtitlesEnabled: z.boolean().optional(),
   /** @deprecated */
   mutedThreads: z.array(z.string()),
 })
@@ -133,6 +134,7 @@ export const defaults: Schema = {
   disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
   kawaii: false,
   hasCheckedForStarterPack: false,
+  subtitlesEnabled: true,
 }
 
 export function tryParse(rawData: string): Schema | undefined {
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index e6b53d5be..c7eaf2726 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
 import {Provider as KawaiiProvider} from './kawaii'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
+import {Provider as SubtitlesProvider} from './subtitles'
 import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
 
 export {
@@ -24,6 +25,7 @@ export {
 export * from './hidden-posts'
 export {useLabelDefinitions} from './label-defs'
 export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
+export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
                 <DisableHapticsProvider>
                   <AutoplayProvider>
                     <UsedStarterPacksProvider>
-                      <KawaiiProvider>{children}</KawaiiProvider>
+                      <SubtitlesProvider>
+                        <KawaiiProvider>{children}</KawaiiProvider>
+                      </SubtitlesProvider>
                     </UsedStarterPacksProvider>
                   </AutoplayProvider>
                 </DisableHapticsProvider>
diff --git a/src/state/preferences/subtitles.tsx b/src/state/preferences/subtitles.tsx
new file mode 100644
index 000000000..e0e89feb1
--- /dev/null
+++ b/src/state/preferences/subtitles.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = boolean
+type SetContext = (v: boolean) => void
+
+const stateContext = React.createContext<StateContext>(
+  Boolean(persisted.defaults.subtitlesEnabled),
+)
+const setContext = React.createContext<SetContext>((_: boolean) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [state, setState] = React.useState(
+    Boolean(persisted.get('subtitlesEnabled')),
+  )
+
+  const setStateWrapped = React.useCallback(
+    (subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
+      setState(Boolean(subtitlesEnabled))
+      persisted.write('subtitlesEnabled', subtitlesEnabled)
+    },
+    [setState],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
+      setState(Boolean(nextSubtitlesEnabled))
+    })
+  }, [setStateWrapped])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export const useSubtitlesEnabled = () => React.useContext(stateContext)
+export const useSetSubtitlesEnabled = () => React.useContext(setContext)
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
+}