[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,45 +108,42 @@ 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: key={currentAccount?.did}>
key={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <StatsigProvider>
<StatsigProvider> <MessagesProvider>
<MessagesProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <LabelDefsProvider>
<LabelDefsProvider> <ModerationOptsProvider>
<ModerationOptsProvider> <LoggedOutViewProvider>
<LoggedOutViewProvider> <SelectedFeedProvider>
<SelectedFeedProvider> <HiddenRepliesProvider>
<HiddenRepliesProvider> <UnreadNotifsProvider>
<UnreadNotifsProvider> <BackgroundNotificationPreferencesProvider>
<BackgroundNotificationPreferencesProvider> <MutedThreadsProvider>
<MutedThreadsProvider> <ProgressGuideProvider>
<ProgressGuideProvider> <GestureHandlerRootView style={s.h100pct}>
<GestureHandlerRootView <TestCtrls />
style={s.h100pct}> <Shell />
<TestCtrls /> <NuxDialogs />
<Shell /> </GestureHandlerRootView>
<NuxDialogs /> </ProgressGuideProvider>
</GestureHandlerRootView> </MutedThreadsProvider>
</ProgressGuideProvider> </BackgroundNotificationPreferencesProvider>
</MutedThreadsProvider> </UnreadNotifsProvider>
</BackgroundNotificationPreferencesProvider> </HiddenRepliesProvider>
</UnreadNotifsProvider> </SelectedFeedProvider>
</HiddenRepliesProvider> </LoggedOutViewProvider>
</SelectedFeedProvider> </ModerationOptsProvider>
</LoggedOutViewProvider> </LabelDefsProvider>
</ModerationOptsProvider> </MessagesProvider>
</LabelDefsProvider> </StatsigProvider>
</MessagesProvider> </QueryProvider>
</StatsigProvider> </React.Fragment>
</QueryProvider> </RootSiblingParent>
</React.Fragment>
</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 [isLoading, setIsLoading] = React.useState(false)
const [isFullscreen, setIsFullscreen] = React.useState(false) const [isActive, setIsActive] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0) const showSpinner = useThrottledValue(isActive && isLoading, 100)
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 showOverlay =
const [error, setError] = useState<Error | PlayerError | null>(null) !isActive ||
if (error) { isLoading ||
throw error (status === 'paused' && !isActive) ||
} status === 'pending'
useEffect(() => { React.useEffect(() => {
if (isActive) { if (!isActive && status !== 'pending') {
// eslint-disable-next-line @typescript-eslint/no-shadow setStatus('pending')
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()
}
} }
}, [player, isActive, disableAutoplay]) }, [isActive, status])
// 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()
}
}
}
}
return ( return (
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> <>
{isActive ? ( <VideoEmbedInnerNative
<VideoEmbedInnerNative embed={embed}
embed={embed} setStatus={setStatus}
timeRemaining={timeRemaining} setIsLoading={setIsLoading}
isMuted={isMuted} setIsActive={setIsActive}
isFullscreen={isFullscreen} ref={ref}
setIsFullscreen={setIsFullscreen} />
/>
) : 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,137 +1,136 @@
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(
embed, function VideoEmbedInnerNative(
isFullscreen, {
setIsFullscreen, embed,
isMuted, setStatus,
timeRemaining, setIsLoading,
}: { setIsActive,
embed: AppBskyEmbedVideo.View }: {
isFullscreen: boolean embed: AppBskyEmbedVideo.View
setIsFullscreen: (isFullscreen: boolean) => void setStatus: (status: 'playing' | 'paused') => void
timeRemaining: number setIsLoading: (isLoading: boolean) => void
isMuted: boolean setIsActive: (isActive: boolean) => void
}) { },
const {_} = useLingui() ref: React.Ref<{togglePlayback: () => void}>,
const {player} = useActiveVideoNative() ) {
const ref = useRef<VideoView>(null) const {_} = useLingui()
const videoRef = useRef<BlueskyVideoView>(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>()
let aspectRatio = 16 / 9 React.useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
}))
if (embed.aspectRatio) { if (error) {
const {width, height} = embed.aspectRatio throw new Error(error)
aspectRatio = width / height }
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
}
return ( let aspectRatio = 16 / 9
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
<VideoView if (embed.aspectRatio) {
ref={ref} const {width, height} = embed.aspectRatio
player={player} aspectRatio = width / height
style={[a.flex_1, a.rounded_sm]} aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
contentFit="cover" }
nativeControls={isFullscreen}
accessibilityIgnoresInvertColors return (
onFullscreenEnter={() => { <View style={[a.flex_1, a.relative, {aspectRatio}]}>
PlatformInfo.setAudioCategory(AudioCategory.Playback) <BlueskyVideoView
PlatformInfo.setAudioActive(true) url={embed.playlist}
player.muted = false autoplay={!autoplayDisabled && !isWithinMessage}
setIsFullscreen(true) beginMuted={true}
if (isAndroid) { style={[a.rounded_sm]}
player.play() onActiveChange={e => {
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`)
} }
}} accessibilityHint=""
onFullscreenExit={() => { />
PlatformInfo.setAudioCategory(AudioCategory.Ambient) <VideoControls
PlatformInfo.setAudioActive(false) enterFullscreen={() => {
player.muted = true videoRef.current?.enterFullscreen()
player.playbackRate = 1 }}
setIsFullscreen(false) toggleMuted={() => {
}} videoRef.current?.toggleMuted()
accessibilityLabel={ }}
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) togglePlayback={() => {
} videoRef.current?.togglePlayback()
accessibilityHint="" }}
/> isMuted={isMuted}
<VideoControls isPlaying={isPlaying}
player={player} timeRemaining={timeRemaining}
enterFullscreen={enterFullscreen} />
isMuted={isMuted} <MediaInsetBorder />
timeRemaining={timeRemaining} </View>
/> )
<MediaInsetBorder /> },
</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,44 +139,80 @@ 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"
/> />
<Animated.View <ControlButton
entering={FadeInDown.duration(300)} onPress={togglePlayback}
style={[ label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
a.absolute, accessibilityHint={_(msg`Tap to play or pause`)}
a.rounded_full, style={{left: 6}}>
a.justify_center, {isPlaying ? (
{ <PauseIcon width={13} fill={t.palette.white} />
backgroundColor: 'rgba(0, 0, 0, 0.5)', ) : (
paddingHorizontal: 4, <PlayIcon width={13} fill={t.palette.white} />
paddingVertical: 4, )}
bottom: 6, </ControlButton>
right: 6, {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
minHeight: 21,
minWidth: 21, <ControlButton
}, onPress={toggleMuted}
]}> label={isMuted ? _(msg`Unmute`) : _(msg`Mute`)}
<Pressable accessibilityHint={_(msg`Tap to toggle sound`)}
onPress={toggleMuted} style={{right: 6}}>
style={a.flex_1} {isMuted ? (
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} <MuteIcon width={13} fill={t.palette.white} />
accessibilityHint={_(msg`Tap to toggle sound`)} ) : (
accessibilityRole="button" <UnmuteIcon width={13} fill={t.palette.white} />
hitSlop={HITSLOP_30}> )}
{isMuted ? ( </ControlButton>
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</Pressable>
</Animated.View>
</View> </View>
) )
} }
function ControlButton({
onPress,
children,
label,
accessibilityHint,
style,
}: {
onPress: () => void
children: React.ReactNode
label: string
accessibilityHint: string
style?: StyleProp<ViewStyle>
}) {
return (
<Animated.View
entering={FadeInDown.duration(300)}
style={[
a.absolute,
a.rounded_full,
a.justify_center,
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 4,
paddingVertical: 4,
bottom: 6,
minHeight: 21,
minWidth: 21,
},
style,
]}>
<Pressable
onPress={onPress}
style={a.flex_1}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
accessibilityRole="button"
hitSlop={HITSLOP_30}>
{children}
</Pressable>
</Animated.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"