diff --git a/app.config.js b/app.config.js
index 25014ee8..ddd72f75 100644
--- a/app.config.js
+++ b/app.config.js
@@ -211,7 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
- 'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
diff --git a/package.json b/package.json
index 92b6cfe1..5401d5f7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.91.0",
+ "version": "1.91.1",
"private": true,
"engines": {
"node": ">=18"
@@ -68,6 +68,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
+ "@haileyok/bluesky-video": "0.1.2",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
@@ -139,7 +140,6 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
- "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 83f133e9..04fea126 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -52,7 +52,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
-import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -63,7 +62,6 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
-import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync()
@@ -110,45 +108,42 @@ function InnerApp() {
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -159,8 +154,6 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
- PlatformInfo.setAudioCategory(AudioCategory.Ambient)
- PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true))
}, [])
diff --git a/src/components/video/PlayButtonIcon.tsx b/src/components/video/PlayButtonIcon.tsx
index 90e93f74..8e0a6bb7 100644
--- a/src/components/video/PlayButtonIcon.tsx
+++ b/src/components/video/PlayButtonIcon.tsx
@@ -4,7 +4,7 @@ import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
-export function PlayButtonIcon({size = 36}: {size?: number}) {
+export function PlayButtonIcon({size = 32}: {size?: number}) {
const t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 60b467d6..b1bfd671 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,8 +1,7 @@
-/* eslint-disable @typescript-eslint/no-shadow */
import React from 'react'
import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
-import {useVideoPlayer, VideoView} from 'expo-video'
+import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
@@ -22,15 +21,8 @@ export function VideoPreview({
clear: () => void
}) {
const t = useTheme()
+ const playerRef = React.useRef(null)
const autoplayDisabled = useAutoplayDisabled()
- const player = useVideoPlayer(video.uri, player => {
- player.loop = true
- player.muted = true
- if (!autoplayDisabled) {
- player.play()
- }
- })
-
let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) {
@@ -50,12 +42,12 @@ export function VideoPreview({
t.atoms.border_contrast_low,
{backgroundColor: 'black'},
]}>
-
{autoplayDisabled && (
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 79dd2f49..f9aeae1a 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,6 +1,7 @@
import React, {memo} from 'react'
import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
+import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {usePalette} from '#/lib/hooks/usePalette'
@@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
import {isIOS} from 'platform/detection'
-import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL
@@ -69,7 +69,7 @@ function ListImpl(
onBeginDragFromContext?.(e, ctx)
},
onEndDrag(e, ctx) {
- runOnJS(updateActiveViewAsync)()
+ runOnJS(updateActiveVideoViewAsync)()
onEndDragFromContext?.(e, ctx)
},
onScroll(e, ctx) {
@@ -84,13 +84,13 @@ function ListImpl(
}
if (isIOS) {
- runOnJS(dedupe)(updateActiveViewAsync)
+ runOnJS(dedupe)(updateActiveVideoViewAsync)
}
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) {
- runOnJS(updateActiveViewAsync)()
+ runOnJS(updateActiveVideoViewAsync)()
onMomentumEndFromContext?.(e, ctx)
},
})
diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
deleted file mode 100644
index 95fa0bb0..00000000
--- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react'
-import {useVideoPlayer, VideoPlayer} from 'expo-video'
-
-import {isAndroid, isNative} from '#/platform/detection'
-
-const Context = React.createContext<{
- activeSource: string
- activeViewId: string | undefined
- setActiveSource: (src: string | null, viewId: string | null) => void
- player: VideoPlayer
-} | null>(null)
-
-export function Provider({children}: {children: React.ReactNode}) {
- if (!isNative) {
- throw new Error('ActiveVideoProvider may only be used on native.')
- }
-
- const [activeSource, setActiveSource] = React.useState('')
- const [activeViewId, setActiveViewId] = React.useState()
-
- const player = useVideoPlayer(activeSource, p => {
- p.muted = true
- p.loop = true
- // We want to immediately call `play` so we get the loading state
- p.play()
- })
-
- const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
- // HACK
- // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
- // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
- // apply it there.
- if (src === activeSource && isAndroid) {
- setActiveSource('')
- setTimeout(() => {
- setActiveSource(src ? src : '')
- }, 100)
- } else {
- setActiveSource(src ? src : '')
- }
- setActiveViewId(viewId ? viewId : '')
- }
-
- return (
-
- {children}
-
- )
-}
-
-export function useActiveVideoNative() {
- const context = React.useContext(Context)
- if (!context) {
- throw new Error(
- 'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
- )
- }
- return context
-}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index a672830d..267b5d18 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,22 +1,18 @@
-import React, {useCallback, useEffect, useId, useState} from 'react'
+import React, {useCallback, useState} from 'react'
import {View} from 'react-native'
import {ImageBackground} from 'expo-image'
-import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
-import {useAutoplayDisabled} from 'state/preferences'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
-import {useIsWithinMessage} from '#/components/dms/MessageContext'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
-import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary'
-import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
@@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
- const {activeSource, activeViewId, setActiveSource, player} =
- useActiveVideoNative()
- const viewId = useId()
+ const ref = React.useRef<{togglePlayback: () => void}>(null)
- const [playerStatus, setPlayerStatus] = useState<
- VideoPlayerStatus | 'paused'
- >('paused')
- const [isMuted, setIsMuted] = useState(player.muted)
- const [isFullscreen, setIsFullscreen] = React.useState(false)
- const [timeRemaining, setTimeRemaining] = React.useState(0)
- const isWithinMessage = useIsWithinMessage()
- const disableAutoplay = useAutoplayDisabled() || isWithinMessage
- const isActive = embed.playlist === activeSource && activeViewId === viewId
- // There are some different loading states that we should pay attention to and show a spinner for
- const isLoading =
- isActive &&
- (playerStatus === 'waitingToPlayAtSpecifiedRate' ||
- playerStatus === 'loading')
- // This happens whenever the visibility view decides that another video should start playing
- const showOverlay = !isActive || isLoading || playerStatus === 'paused'
+ const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
+ 'pending',
+ )
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isActive, setIsActive] = React.useState(false)
+ const showSpinner = useThrottledValue(isActive && isLoading, 100)
- // send error up to error boundary
- const [error, setError] = useState(null)
- if (error) {
- throw error
- }
+ const showOverlay =
+ !isActive ||
+ isLoading ||
+ (status === 'paused' && !isActive) ||
+ status === 'pending'
- useEffect(() => {
- if (isActive) {
- // eslint-disable-next-line @typescript-eslint/no-shadow
- const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
- setIsMuted(isMuted)
- })
- const timeSub = player.addListener(
- 'timeRemainingChange',
- secondsRemaining => {
- setTimeRemaining(secondsRemaining)
- },
- )
- const statusSub = player.addListener(
- 'statusChange',
- (status, oldStatus, playerError) => {
- setPlayerStatus(status)
- if (status === 'error') {
- setError(playerError ?? new Error('Unknown player error'))
- }
- if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
- player.play()
- }
- },
- )
- return () => {
- volumeSub.remove()
- timeSub.remove()
- statusSub.remove()
- }
+ React.useEffect(() => {
+ if (!isActive && status !== 'pending') {
+ setStatus('pending')
}
- }, [player, isActive, disableAutoplay])
-
- // The source might already be active (for example, if you are scrolling a list of quotes and its all the same
- // video). In those cases, just start playing. Otherwise, setting the active source will result in the video
- // start playback immediately
- const startPlaying = (ignoreAutoplayPreference: boolean) => {
- if (disableAutoplay && !ignoreAutoplayPreference) {
- return
- }
-
- if (isActive) {
- player.play()
- } else {
- setActiveSource(embed.playlist, viewId)
- }
- }
-
- const onVisibilityStatusChange = (isVisible: boolean) => {
- // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change
- // events
- if (isFullscreen) {
- return
- }
- if (isVisible) {
- startPlaying(false)
- } else {
- // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted
- // until it gets replaced by another video
- if (disableAutoplay) {
- setActiveSource(null, null)
- } else {
- player.muted = true
- if (player.playing) {
- player.pause()
- }
- }
- }
- }
+ }, [isActive, status])
return (
-
- {isActive ? (
-
- ) : null}
+ <>
+
-
+ >
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
index be3f9071..66e1df50 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
import {atoms as a, native, useTheme} from '#/alf'
@@ -8,7 +9,13 @@ import {Text} from '#/components/Typography'
* Absolutely positioned time indicator showing how many seconds are remaining
* Time is in seconds
*/
-export function TimeIndicator({time}: {time: number}) {
+export function TimeIndicator({
+ time,
+ style,
+}: {
+ time: number
+ style?: StyleProp
+}) {
const t = useTheme()
if (isNaN(time)) {
@@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) {
void
- timeRemaining: number
- isMuted: boolean
-}) {
- const {_} = useLingui()
- const {player} = useActiveVideoNative()
- const ref = useRef(null)
+export const VideoEmbedInnerNative = React.forwardRef(
+ function VideoEmbedInnerNative(
+ {
+ embed,
+ setStatus,
+ setIsLoading,
+ setIsActive,
+ }: {
+ embed: AppBskyEmbedVideo.View
+ setStatus: (status: 'playing' | 'paused') => void
+ setIsLoading: (isLoading: boolean) => void
+ setIsActive: (isActive: boolean) => void
+ },
+ ref: React.Ref<{togglePlayback: () => void}>,
+ ) {
+ const {_} = useLingui()
+ const videoRef = useRef(null)
+ const autoplayDisabled = useAutoplayDisabled()
+ const isWithinMessage = useIsWithinMessage()
- const enterFullscreen = useCallback(() => {
- ref.current?.enterFullscreen()
- }, [])
+ const [isMuted, setIsMuted] = React.useState(true)
+ const [isPlaying, setIsPlaying] = React.useState(false)
+ const [timeRemaining, setTimeRemaining] = React.useState(0)
+ const [error, setError] = React.useState()
- let aspectRatio = 16 / 9
+ React.useImperativeHandle(ref, () => ({
+ togglePlayback: () => {
+ videoRef.current?.togglePlayback()
+ },
+ }))
- if (embed.aspectRatio) {
- const {width, height} = embed.aspectRatio
- aspectRatio = width / height
- aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
- }
+ if (error) {
+ throw new Error(error)
+ }
- return (
-
- {
- PlatformInfo.setAudioCategory(AudioCategory.Playback)
- PlatformInfo.setAudioActive(true)
- player.muted = false
- setIsFullscreen(true)
- if (isAndroid) {
- player.play()
+ let aspectRatio = 16 / 9
+
+ if (embed.aspectRatio) {
+ const {width, height} = embed.aspectRatio
+ aspectRatio = width / height
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+ }
+
+ return (
+
+ {
+ setIsActive(e.nativeEvent.isActive)
+ }}
+ onLoadingChange={e => {
+ setIsLoading(e.nativeEvent.isLoading)
+ }}
+ onMutedChange={e => {
+ setIsMuted(e.nativeEvent.isMuted)
+ }}
+ onStatusChange={e => {
+ setStatus(e.nativeEvent.status)
+ setIsPlaying(e.nativeEvent.status === 'playing')
+ }}
+ onTimeRemainingChange={e => {
+ setTimeRemaining(e.nativeEvent.timeRemaining)
+ }}
+ onError={e => {
+ setError(e.nativeEvent.error)
+ }}
+ ref={videoRef}
+ accessibilityLabel={
+ embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
}
- }}
- onFullscreenExit={() => {
- PlatformInfo.setAudioCategory(AudioCategory.Ambient)
- PlatformInfo.setAudioActive(false)
- player.muted = true
- player.playbackRate = 1
- setIsFullscreen(false)
- }}
- accessibilityLabel={
- embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
- }
- accessibilityHint=""
- />
-
-
-
- )
-}
+ accessibilityHint=""
+ />
+ {
+ videoRef.current?.enterFullscreen()
+ }}
+ toggleMuted={() => {
+ videoRef.current?.toggleMuted()
+ }}
+ togglePlayback={() => {
+ videoRef.current?.togglePlayback()
+ }}
+ isMuted={isMuted}
+ isPlaying={isPlaying}
+ timeRemaining={timeRemaining}
+ />
+
+
+ )
+ },
+)
function VideoControls({
- player,
enterFullscreen,
+ toggleMuted,
+ togglePlayback,
timeRemaining,
+ isPlaying,
isMuted,
}: {
- player: VideoPlayer
enterFullscreen: () => void
+ toggleMuted: () => void
+ togglePlayback: () => void
timeRemaining: number
+ isPlaying: boolean
isMuted: boolean
}) {
const {_} = useLingui()
const t = useTheme()
- const onPressFullscreen = useCallback(() => {
- switch (player.status) {
- case 'idle':
- case 'loading':
- case 'readyToPlay': {
- if (!player.playing) player.play()
- enterFullscreen()
- break
- }
- case 'error': {
- player.replay()
- break
- }
- }
- }, [player, enterFullscreen])
-
- const toggleMuted = useCallback(() => {
- const muted = !player.muted
- // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when
- // the video is muted, and vice versa.
- const mix = !muted
- const category = muted ? AudioCategory.Ambient : AudioCategory.Playback
-
- PlatformInfo.setAudioCategory(category)
- PlatformInfo.setAudioActive(mix)
- player.muted = muted
- }, [player])
-
// show countdown when:
// 1. timeRemaining is a number - was seeing NaNs
// 2. duration is greater than 0 - means metadata has loaded
@@ -140,44 +139,80 @@ function VideoControls({
return (
- {showTime && }
-
-
- {isMuted ? (
-
- ) : (
-
- )}
-
-
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ {showTime && }
+
+
+ {isMuted ? (
+
+ ) : (
+
+ )}
+
)
}
+
+function ControlButton({
+ onPress,
+ children,
+ label,
+ accessibilityHint,
+ style,
+}: {
+ onPress: () => void
+ children: React.ReactNode
+ label: string
+ accessibilityHint: string
+ style?: StyleProp
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/yarn.lock b/yarn.lock
index b2e389aa..5fd07230 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4104,6 +4104,11 @@
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
+"@haileyok/bluesky-video@0.1.2":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.2.tgz#53abb04c22885fcf8a1d8a7510d2cfbe7d45ff91"
+ integrity sha512-OPltVPNhjrm/+d4YYbaSsKLK7VQWC62ci8J05GO4I/PhWsYLWsAu79CGfZ1YTpfpIjYXyo0HjMmiig5X/hhOsQ==
+
"@hapi/accept@^6.0.3":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab"
@@ -12415,10 +12420,6 @@ expo-updates@~0.25.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
-"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz":
- version "1.2.4"
- resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz#4127dd5cea5fdf7ab745104c73b8ecf5506f5d34"
-
expo-web-browser@~13.0.3:
version "13.0.3"
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"