[Video] Remove `expo-video`, use `bluesky-video` (#5282)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
zio/dev^2
Hailey 2024-09-13 12:44:42 -07:00 committed by GitHub
parent 78a531f5ff
commit 26508cfe6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 269 additions and 385 deletions

View File

@ -211,7 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
}, },
], ],
'expo-video',
'react-native-compressor', 'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.91.0", "version": "1.91.1",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -68,6 +68,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2", "@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.1.2",
"@lingui/react": "^4.5.0", "@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1", "@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1", "@miblanchard/react-native-slider": "^2.3.1",
@ -139,7 +140,6 @@
"expo-system-ui": "~3.0.4", "expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1", "expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14", "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", "expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",

View File

@ -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 StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' 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 * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell' import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf' 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 {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@ -110,7 +108,6 @@ function InnerApp() {
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}> <Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent> <RootSiblingParent>
<React.Fragment <React.Fragment
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
@ -128,8 +125,7 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <ProgressGuideProvider>
<GestureHandlerRootView <GestureHandlerRootView style={s.h100pct}>
style={s.h100pct}>
<TestCtrls /> <TestCtrls />
<Shell /> <Shell />
<NuxDialogs /> <NuxDialogs />
@ -148,7 +144,6 @@ function InnerApp() {
</QueryProvider> </QueryProvider>
</React.Fragment> </React.Fragment>
</RootSiblingParent> </RootSiblingParent>
</ActiveVideoProvider>
</Splash> </Splash>
</ThemeProvider> </ThemeProvider>
</Alf> </Alf>
@ -159,8 +154,6 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true)) initPersistedState().then(() => setReady(true))
}, []) }, [])

View File

@ -4,7 +4,7 @@ import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 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 t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975 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 const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25

View File

@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker' 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 {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
@ -22,15 +21,8 @@ export function VideoPreview({
clear: () => void clear: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const playerRef = React.useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled() 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 let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) { if (isNaN(aspectRatio)) {
@ -50,12 +42,12 @@ export function VideoPreview({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{backgroundColor: 'black'}, {backgroundColor: 'black'},
]}> ]}>
<VideoView <BlueskyVideoView
player={player} url={video.uri}
style={a.flex_1} autoplay={autoplayDisabled}
allowsPictureInPicture={false} beginMuted={true}
nativeControls={false} forceTakeover={true}
contentFit="contain" ref={playerRef}
/> />
<ExternalEmbedRemoveBtn onRemove={clear} /> <ExternalEmbedRemoveBtn onRemove={clear} />
{autoplayDisabled && ( {autoplayDisabled && (

View File

@ -1,6 +1,7 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {FlatListProps, RefreshControl, ViewToken} from 'react-native' import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
import {runOnJS, useSharedValue} from 'react-native-reanimated' import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe' import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views' import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL export type ListMethods = FlatList_INTERNAL
@ -69,7 +69,7 @@ function ListImpl<ItemT>(
onBeginDragFromContext?.(e, ctx) onBeginDragFromContext?.(e, ctx)
}, },
onEndDrag(e, ctx) { onEndDrag(e, ctx) {
runOnJS(updateActiveViewAsync)() runOnJS(updateActiveVideoViewAsync)()
onEndDragFromContext?.(e, ctx) onEndDragFromContext?.(e, ctx)
}, },
onScroll(e, ctx) { onScroll(e, ctx) {
@ -84,13 +84,13 @@ function ListImpl<ItemT>(
} }
if (isIOS) { if (isIOS) {
runOnJS(dedupe)(updateActiveViewAsync) runOnJS(dedupe)(updateActiveVideoViewAsync)
} }
}, },
// Note: adding onMomentumBegin here makes simulator scroll // Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why. // lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) { onMomentumEnd(e, ctx) {
runOnJS(updateActiveViewAsync)() runOnJS(updateActiveVideoViewAsync)()
onMomentumEndFromContext?.(e, ctx) onMomentumEndFromContext?.(e, ctx)
}, },
}) })

View File

@ -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<string>()
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 (
<Context.Provider
value={{
activeSource,
setActiveSource: setActiveSourceOuter,
activeViewId,
player,
}}>
{children}
</Context.Provider>
)
}
export function useActiveVideoNative() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
)
}
return context
}

View File

@ -1,22 +1,18 @@
import React, {useCallback, useEffect, useId, useState} from 'react' import React, {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImageBackground} from 'expo-image' import {ImageBackground} from 'expo-image'
import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {useAutoplayDisabled} from 'state/preferences'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary' import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props { interface Props {
@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) { function InnerWrapper({embed}: Props) {
const {_} = useLingui() const {_} = useLingui()
const {activeSource, activeViewId, setActiveSource, player} = const ref = React.useRef<{togglePlayback: () => void}>(null)
useActiveVideoNative()
const viewId = useId()
const [playerStatus, setPlayerStatus] = useState< const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
VideoPlayerStatus | 'paused' 'pending',
>('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'
// send error up to error boundary
const [error, setError] = useState<Error | PlayerError | null>(null)
if (error) {
throw error
}
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( const [isLoading, setIsLoading] = React.useState(false)
'statusChange', const [isActive, setIsActive] = React.useState(false)
(status, oldStatus, playerError) => { const showSpinner = useThrottledValue(isActive && isLoading, 100)
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()
}
}
}, [player, isActive, disableAutoplay])
// The source might already be active (for example, if you are scrolling a list of quotes and its all the same const showOverlay =
// video). In those cases, just start playing. Otherwise, setting the active source will result in the video !isActive ||
// start playback immediately isLoading ||
const startPlaying = (ignoreAutoplayPreference: boolean) => { (status === 'paused' && !isActive) ||
if (disableAutoplay && !ignoreAutoplayPreference) { status === 'pending'
return
}
if (isActive) { React.useEffect(() => {
player.play() if (!isActive && status !== 'pending') {
} else { setStatus('pending')
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 ( return (
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> <>
{isActive ? (
<VideoEmbedInnerNative <VideoEmbedInnerNative
embed={embed} embed={embed}
timeRemaining={timeRemaining} setStatus={setStatus}
isMuted={isMuted} setIsLoading={setIsLoading}
isFullscreen={isFullscreen} setIsActive={setIsActive}
setIsFullscreen={setIsFullscreen} ref={ref}
/> />
) : null}
<ImageBackground <ImageBackground
source={{uri: embed.thumbnail}} source={{uri: embed.thumbnail}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
@ -185,17 +104,18 @@ function InnerWrapper({embed}: Props) {
> >
<Button <Button
style={[a.flex_1, a.align_center, a.justify_center]} style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => startPlaying(true)} onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)} label={_(msg`Play video`)}
color="secondary"> color="secondary">
{isLoading ? ( {showSpinner ? (
<View <View
style={[ style={[
a.rounded_full, a.rounded_full,
a.p_xs, a.p_xs,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}> ]}>
<Loader size="2xl" style={{color: 'white'}} /> <Loader size="2xl" style={{color: 'white'}} />
</View> </View>
@ -204,7 +124,7 @@ function InnerWrapper({embed}: Props) {
)} )}
</Button> </Button>
</ImageBackground> </ImageBackground>
</VisibilityView> </>
) )
} }

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
import {atoms as a, native, useTheme} from '#/alf' 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 * Absolutely positioned time indicator showing how many seconds are remaining
* Time is in seconds * Time is in seconds
*/ */
export function TimeIndicator({time}: {time: number}) { export function TimeIndicator({
time,
style,
}: {
time: number
style?: StyleProp<ViewStyle>
}) {
const t = useTheme() const t = useTheme()
if (isNaN(time)) { if (isNaN(time)) {
@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) {
<Animated.View <Animated.View
entering={native(FadeInDown.duration(300))} entering={native(FadeInDown.duration(300))}
exiting={native(FadeOutDown.duration(500))} exiting={native(FadeOutDown.duration(500))}
pointerEvents="none"
style={[ style={[
{ {
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute',
left: 6, left: 6,
bottom: 6, bottom: 6,
minHeight: 21, minHeight: 21,
justifyContent: 'center',
}, },
a.absolute,
a.justify_center,
style,
]}> ]}>
<Text <Text
style={[ style={[

View File

@ -1,45 +1,57 @@
import React, {useCallback, useRef} from 'react' import React, {useRef} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated' import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {isAndroid} from 'platform/detection' import {useAutoplayDisabled} from '#/state/preferences'
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
AudioCategory,
PlatformInfo,
} from '../../../../../../modules/expo-bluesky-swiss-army'
import {TimeIndicator} from './TimeIndicator' import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({ export const VideoEmbedInnerNative = React.forwardRef(
function VideoEmbedInnerNative(
{
embed, embed,
isFullscreen, setStatus,
setIsFullscreen, setIsLoading,
isMuted, setIsActive,
timeRemaining,
}: { }: {
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
isFullscreen: boolean setStatus: (status: 'playing' | 'paused') => void
setIsFullscreen: (isFullscreen: boolean) => void setIsLoading: (isLoading: boolean) => void
timeRemaining: number setIsActive: (isActive: boolean) => void
isMuted: boolean },
}) { ref: React.Ref<{togglePlayback: () => void}>,
) {
const {_} = useLingui() const {_} = useLingui()
const {player} = useActiveVideoNative() const videoRef = useRef<BlueskyVideoView>(null)
const ref = useRef<VideoView>(null) const autoplayDisabled = useAutoplayDisabled()
const isWithinMessage = useIsWithinMessage()
const enterFullscreen = useCallback(() => { const [isMuted, setIsMuted] = React.useState(true)
ref.current?.enterFullscreen() const [isPlaying, setIsPlaying] = React.useState(false)
}, []) const [timeRemaining, setTimeRemaining] = React.useState(0)
const [error, setError] = React.useState<string>()
React.useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
}))
if (error) {
throw new Error(error)
}
let aspectRatio = 16 / 9 let aspectRatio = 16 / 9
@ -51,87 +63,74 @@ export function VideoEmbedInnerNative({
return ( return (
<View style={[a.flex_1, a.relative, {aspectRatio}]}> <View style={[a.flex_1, a.relative, {aspectRatio}]}>
<VideoView <BlueskyVideoView
ref={ref} url={embed.playlist}
player={player} autoplay={!autoplayDisabled && !isWithinMessage}
style={[a.flex_1, a.rounded_sm]} beginMuted={true}
contentFit="cover" style={[a.rounded_sm]}
nativeControls={isFullscreen} onActiveChange={e => {
accessibilityIgnoresInvertColors setIsActive(e.nativeEvent.isActive)
onFullscreenEnter={() => {
PlatformInfo.setAudioCategory(AudioCategory.Playback)
PlatformInfo.setAudioActive(true)
player.muted = false
setIsFullscreen(true)
if (isAndroid) {
player.play()
}
}} }}
onFullscreenExit={() => { onLoadingChange={e => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient) setIsLoading(e.nativeEvent.isLoading)
PlatformInfo.setAudioActive(false)
player.muted = true
player.playbackRate = 1
setIsFullscreen(false)
}} }}
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={ accessibilityLabel={
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
} }
accessibilityHint="" accessibilityHint=""
/> />
<VideoControls <VideoControls
player={player} enterFullscreen={() => {
enterFullscreen={enterFullscreen} videoRef.current?.enterFullscreen()
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isMuted={isMuted} isMuted={isMuted}
isPlaying={isPlaying}
timeRemaining={timeRemaining} timeRemaining={timeRemaining}
/> />
<MediaInsetBorder /> <MediaInsetBorder />
</View> </View>
) )
} },
)
function VideoControls({ function VideoControls({
player,
enterFullscreen, enterFullscreen,
toggleMuted,
togglePlayback,
timeRemaining, timeRemaining,
isPlaying,
isMuted, isMuted,
}: { }: {
player: VideoPlayer
enterFullscreen: () => void enterFullscreen: () => void
toggleMuted: () => void
togglePlayback: () => void
timeRemaining: number timeRemaining: number
isPlaying: boolean
isMuted: boolean isMuted: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() 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: // show countdown when:
// 1. timeRemaining is a number - was seeing NaNs // 1. timeRemaining is a number - was seeing NaNs
// 2. duration is greater than 0 - means metadata has loaded // 2. duration is greater than 0 - means metadata has loaded
@ -140,14 +139,55 @@ function VideoControls({
return ( return (
<View style={[a.absolute, a.inset_0]}> <View style={[a.absolute, a.inset_0]}>
{showTime && <TimeIndicator time={timeRemaining} />}
<Pressable <Pressable
onPress={onPressFullscreen} onPress={enterFullscreen}
style={a.flex_1} style={a.flex_1}
accessibilityLabel={_(msg`Video`)} accessibilityLabel={_(msg`Video`)}
accessibilityHint={_(msg`Tap to enter full screen`)} accessibilityHint={_(msg`Tap to enter full screen`)}
accessibilityRole="button" accessibilityRole="button"
/> />
<ControlButton
onPress={togglePlayback}
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
accessibilityHint={_(msg`Tap to play or pause`)}
style={{left: 6}}>
{isPlaying ? (
<PauseIcon width={13} fill={t.palette.white} />
) : (
<PlayIcon width={13} fill={t.palette.white} />
)}
</ControlButton>
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
<ControlButton
onPress={toggleMuted}
label={isMuted ? _(msg`Unmute`) : _(msg`Mute`)}
accessibilityHint={_(msg`Tap to toggle sound`)}
style={{right: 6}}>
{isMuted ? (
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</ControlButton>
</View>
)
}
function ControlButton({
onPress,
children,
label,
accessibilityHint,
style,
}: {
onPress: () => void
children: React.ReactNode
label: string
accessibilityHint: string
style?: StyleProp<ViewStyle>
}) {
return (
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
style={[ style={[
@ -159,25 +199,20 @@ function VideoControls({
paddingHorizontal: 4, paddingHorizontal: 4,
paddingVertical: 4, paddingVertical: 4,
bottom: 6, bottom: 6,
right: 6,
minHeight: 21, minHeight: 21,
minWidth: 21, minWidth: 21,
}, },
style,
]}> ]}>
<Pressable <Pressable
onPress={toggleMuted} onPress={onPress}
style={a.flex_1} style={a.flex_1}
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} accessibilityLabel={label}
accessibilityHint={_(msg`Tap to toggle sound`)} accessibilityHint={accessibilityHint}
accessibilityRole="button" accessibilityRole="button"
hitSlop={HITSLOP_30}> hitSlop={HITSLOP_30}>
{isMuted ? ( {children}
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</Pressable> </Pressable>
</Animated.View> </Animated.View>
</View>
) )
} }

View File

@ -4104,6 +4104,11 @@
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== 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": "@hapi/accept@^6.0.3":
version "6.0.3" version "6.0.3"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab" 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" ignore "^5.3.1"
resolve-from "^5.0.0" 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: expo-web-browser@~13.0.3:
version "13.0.3" version "13.0.3"
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92" resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"