[Video] Remove `expo-video`, use `bluesky-video` (#5282)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>zio/dev^2
parent
78a531f5ff
commit
26508cfe6a
|
@ -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',
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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={[
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue