diff --git a/assets/icons/play_filled_corner2_rounded.svg b/assets/icons/play_filled_corner2_rounded.svg
new file mode 100644
index 00000000..e25e8d46
--- /dev/null
+++ b/assets/icons/play_filled_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/play_stroke2_corner2_rounded.svg b/assets/icons/play_stroke2_corner2_rounded.svg
new file mode 100644
index 00000000..54bba91f
--- /dev/null
+++ b/assets/icons/play_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index be22209b..091fb2fd 100644
--- a/package.json
+++ b/package.json
@@ -143,6 +143,7 @@
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
+ "hls.js": "^1.5.11",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
diff --git a/patches/expo-video+1.1.10.patch b/patches/expo-video+1.1.10.patch
new file mode 100644
index 00000000..b183be9d
--- /dev/null
+++ b/patches/expo-video+1.1.10.patch
@@ -0,0 +1,20 @@
+--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
+ setShowPreviousButton(!requireLinearPlayback)
+ setShowNextButton(!requireLinearPlayback)
+ setTimeBarInteractive(requireLinearPlayback)
++ setShowSubtitleButton(true)
+ }
+
+ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
+
+ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+ internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
+- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen)
++ val fullscreenButton =
++ findViewById(androidx.media3.ui.R.id.exo_fullscreen)
+ fullscreenButton?.visibility = if (visible) {
+ android.view.View.VISIBLE
+ } else {
diff --git a/src/App.native.tsx b/src/App.native.tsx
index ed76c753..d2c20fc8 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -23,10 +23,12 @@ import {
} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
+import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -49,6 +51,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -58,8 +61,6 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import I18nProvider from './locale/i18nProvider'
-import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
@@ -107,42 +108,44 @@ function InnerApp() {
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index a64988f3..df6fbf24 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
+import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -37,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -46,8 +49,6 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import I18nProvider from './locale/i18nProvider'
-import {listenSessionDropped} from './state/events'
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -92,39 +93,41 @@ function InnerApp() {
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/icons/Play.tsx b/src/components/icons/Play.tsx
new file mode 100644
index 00000000..acf421d5
--- /dev/null
+++ b/src/components/icons/Play.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+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',
+})
+
+export const Play_Filled_Corner2_Rounded = createSinglePathSVG({
+ path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z',
+})
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index a05339d4..425a2257 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -210,38 +210,40 @@ function PostInner({
)}
-
-
- {richText.text ? (
-
-
-
- ) : undefined}
- {limitLines ? (
-
+
- ) : undefined}
- {post.embed ? (
-
- ) : null}
-
+ {richText.text ? (
+
+
+
+ ) : undefined}
+ {limitLines ? (
+
+ ) : undefined}
+ {post.embed ? (
+
+ ) : null}
+
+ )}
{
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
@@ -354,6 +358,9 @@ let FeedItemInner = ({
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
/>
+ {__DEV__ && gate('videos') && (
+
+ )}
void
+} | null>(null)
+
+export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
+ const [activeViewId, setActiveViewId] = useState(null)
+ const [source, setSource] = useState(null)
+
+ const value = useMemo(
+ () => ({
+ activeViewId,
+ setActiveView: (viewId: string, src: string) => {
+ setActiveViewId(viewId)
+ setSource(src)
+ },
+ }),
+ [activeViewId],
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useActiveVideoView() {
+ const context = React.useContext(ActiveVideoContext)
+ if (!context) {
+ throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
+ }
+ const id = useId()
+
+ return {
+ active: context.activeViewId === id,
+ setActive: useCallback(
+ (source: string) => context.setActiveView(id, source),
+ [context, id],
+ ),
+ }
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
new file mode 100644
index 00000000..5e5293a5
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -0,0 +1,44 @@
+import React, {useCallback} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {useActiveVideoView} from './ActiveVideoContext'
+import {VideoEmbedInner} from './VideoEmbedInner'
+
+export function VideoEmbed({source}: {source: string}) {
+ const t = useTheme()
+ const {active, setActive} = useActiveVideoView()
+ const {_} = useLingui()
+
+ const onPress = useCallback(() => setActive(source), [setActive, source])
+
+ return (
+
+ {active ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
new file mode 100644
index 00000000..ef067870
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
@@ -0,0 +1,138 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
+import Animated, {
+ measure,
+ runOnJS,
+ useAnimatedRef,
+ useFrameCallback,
+ useSharedValue,
+} from 'react-native-reanimated'
+import {VideoPlayer, VideoView} from 'expo-video'
+
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {useVideoPlayer} from './VideoPlayerContext'
+
+export const VideoEmbedInner = ({}: {source: string}) => {
+ const player = useVideoPlayer()
+ const aref = useAnimatedRef()
+ const {height: windowHeight} = useWindowDimensions()
+ const hasLeftView = useSharedValue(false)
+ const ref = useRef(null)
+
+ const onEnterView = useCallback(() => {
+ if (player.status === 'readyToPlay') {
+ player.play()
+ }
+ }, [player])
+
+ const onLeaveView = useCallback(() => {
+ player.pause()
+ }, [player])
+
+ const enterFullscreen = useCallback(() => {
+ if (ref.current) {
+ ref.current.enterFullscreen()
+ }
+ }, [])
+
+ useFrameCallback(() => {
+ const measurement = measure(aref)
+
+ if (measurement) {
+ if (hasLeftView.value) {
+ // Check if the video is in view
+ if (
+ measurement.pageY >= 0 &&
+ measurement.pageY + measurement.height <= windowHeight
+ ) {
+ runOnJS(onEnterView)()
+ hasLeftView.value = false
+ }
+ } else {
+ // Check if the video is out of view
+ if (
+ measurement.pageY + measurement.height < 0 ||
+ measurement.pageY > windowHeight
+ ) {
+ runOnJS(onLeaveView)()
+ hasLeftView.value = true
+ }
+ }
+ }
+ })
+
+ return (
+
+
+
+
+ )
+}
+
+function VideoControls({
+ player,
+ enterFullscreen,
+}: {
+ player: VideoPlayer
+ enterFullscreen: () => void
+}) {
+ const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCurrentTime(Math.floor(player.duration - player.currentTime))
+ // how often should we update the time?
+ // 1000 gets out of sync with the video time
+ }, 250)
+
+ return () => {
+ clearInterval(interval)
+ }
+ }, [player])
+
+ const minutes = Math.floor(currentTime / 60)
+ const seconds = String(currentTime % 60).padStart(2, '0')
+
+ return (
+
+
+
+ {minutes}:{seconds}
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ timeContainer: {
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
+ borderRadius: 6,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ position: 'absolute',
+ left: 5,
+ bottom: 5,
+ },
+ timeElapsed: {
+ color: 'white',
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+})
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
new file mode 100644
index 00000000..cb02743c
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
@@ -0,0 +1,52 @@
+import React, {useEffect, useRef} from 'react'
+import Hls from 'hls.js'
+
+import {atoms as a} from '#/alf'
+
+export const VideoEmbedInner = ({source}: {source: string}) => {
+ const ref = useRef(null)
+
+ // Use HLS.js to play HLS video
+ useEffect(() => {
+ if (ref.current) {
+ if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
+ ref.current.src = source
+ } else if (Hls.isSupported()) {
+ var hls = new Hls()
+ hls.loadSource(source)
+ hls.attachMedia(ref.current)
+ } else {
+ // TODO: fallback
+ }
+ }
+ }, [source])
+
+ useEffect(() => {
+ if (ref.current) {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (ref.current) {
+ if (entry.isIntersecting) {
+ if (ref.current.paused) {
+ ref.current.play()
+ }
+ } else {
+ if (!ref.current.paused) {
+ ref.current.pause()
+ }
+ }
+ }
+ },
+ {threshold: 0},
+ )
+
+ observer.observe(ref.current)
+
+ return () => {
+ observer.disconnect()
+ }
+ }
+ }, [])
+
+ return
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
new file mode 100644
index 00000000..bc5d9d37
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -0,0 +1,41 @@
+import React, {useContext, useEffect} from 'react'
+import type {VideoPlayer} from 'expo-video'
+import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
+
+const VideoPlayerContext = React.createContext(null)
+
+export function VideoPlayerProvider({
+ viewId,
+ source,
+ children,
+}: {
+ viewId: string | null
+ source: string
+ children: React.ReactNode
+}) {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const player = useExpoVideoPlayer(source, player => {
+ player.loop = true
+ player.play()
+ })
+
+ // make sure we're playing every time the viewId changes
+ // this means the video is different
+ useEffect(() => {
+ player.play()
+ }, [viewId, player])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useVideoPlayer() {
+ const context = useContext(VideoPlayerContext)
+ if (!context) {
+ throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
+ }
+ return context
+}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
new file mode 100644
index 00000000..329fb120
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
+ return children
+}
+
+export function useVideoPlayer() {
+ throw new Error('useVideoPlayer must not be used on web')
+}
diff --git a/yarn.lock b/yarn.lock
index 6450d33b..b99f9634 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13345,6 +13345,11 @@ history@^5.3.0:
dependencies:
"@babel/runtime" "^7.7.6"
+hls.js@^1.5.11:
+ version "1.5.11"
+ resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826"
+ integrity sha512-q3We1izi2+qkOO+TvZdHv+dx6aFzdtk3xc1/Qesrvto4thLTT/x/1FK85c5h1qZE4MmMBNgKg+MIW8nxQfxwBw==
+
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"