diff --git a/__mocks__/expo-video.tsx b/__mocks__/expo-video.tsx deleted file mode 100644 index dc6df5395365..000000000000 --- a/__mocks__/expo-video.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -/* eslint-disable @typescript-eslint/naming-convention */ - -/* eslint-disable @typescript-eslint/no-unused-vars */ - -/* eslint-disable react-compiler/react-compiler */ -import React, {forwardRef, useRef} from 'react'; -import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; -import type {ViewProps} from 'react-native'; - -type MutedChangeEventPayload = { - isMuted: boolean; -}; - -type PlayingChangeEventPayload = { - isPlaying: boolean; -}; - -type StatusChangeEventPayload = { - status: string; -}; - -type TimeUpdateEventPayload = { - currentTime: number; -}; - -type VideoPlayer = { - /** lightweight โ€œstateโ€ */ - isPlaying: boolean; - isMuted: boolean; - currentTime: number; - - /** controls */ - play: jest.Mock; - pause: jest.Mock; - replace: jest.Mock; - seekTo: jest.Mock; - setIsMuted: jest.Mock; - - /** simple event API */ - addListener: jest.Mock<{remove: () => void}, [string, (payload: unknown) => void]>; -}; - -function createMockPlayer(): VideoPlayer { - let _isPlaying = false; - let _isMuted = false; - let _currentTime = 0; - - return { - get isPlaying() { - return _isPlaying; - }, - get isMuted() { - return _isMuted; - }, - get currentTime() { - return _currentTime; - }, - - play: jest.fn(() => { - _isPlaying = true; - }), - pause: jest.fn(() => { - _isPlaying = false; - }), - replace: jest.fn((_opts?: unknown) => { - // no-op; exist to satisfy code that calls it - }), - seekTo: jest.fn((time: number) => { - _currentTime = time; - }), - setIsMuted: jest.fn((muted: boolean) => { - _isMuted = muted; - }), - - addListener: jest.fn((_event: string, _cb: (payload: unknown) => void) => { - // minimal, enough for code that calls .remove() - return {remove: () => {}}; - }), - }; -} - -/** - * Mocked hook โ€“ returns a stable mock player instance. - * Signature accepts any args to match real API calls. - */ -function useVideoPlayer(..._args: unknown[]): VideoPlayer { - const ref = useRef(null); - if (!ref.current) { - ref.current = createMockPlayer(); - } - return ref.current; -} - -/** - * Simple stand-in for the native VideoView. - * Accepts a `player` prop to mirror the real component. - */ -type VideoViewProps = ViewProps & {player?: VideoPlayer}; - -const VideoView = forwardRef((props: VideoViewProps, ref: ForwardedRef) => ( - // eslint-disable-next-line react/jsx-props-no-spreading - -)); - -export {useVideoPlayer, VideoView}; -export type {MutedChangeEventPayload, PlayingChangeEventPayload, StatusChangeEventPayload, TimeUpdateEventPayload}; diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index d8b8d683260b..4450bb12d66d 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -46,7 +46,6 @@ const includeModules = [ '@react-native/assets', 'expo', 'expo-av', - 'expo-video', 'expo-image-manipulator', 'expo-modules-core', ].join('|'); diff --git a/cspell.json b/cspell.json index 744d8900db04..ec608a23cfa7 100644 --- a/cspell.json +++ b/cspell.json @@ -698,7 +698,6 @@ "unhold", "Unlaminated", "Unmigrated", - "unmuting", "unredacted", "unscrollable", "unsubmitted", diff --git a/package-lock.json b/package-lock.json index 067b98ce8070..1bbf17ab015b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "expo-image-manipulator": "^13.1.5", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", - "expo-video": "^3.0.11", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -22989,17 +22988,6 @@ "expo": "*" } }, - "node_modules/expo-video": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-3.0.11.tgz", - "integrity": "sha512-k/xz8Ml/LekuD2U2LomML2mUISvkHzYDz3fXY8Au1fEaYVNTfTs7Gyfo1lvF6S1X7u3XutoAfew8e8e1ZUR2fg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.8", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.8.tgz", diff --git a/package.json b/package.json index 4d51a84c06d5..ca7a5816fc6b 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "expo-image-manipulator": "^13.1.5", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", - "expo-video": "^3.0.11", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", diff --git a/patches/expo-av/details.md b/patches/expo-av/details.md new file mode 100644 index 000000000000..e24511e138a1 --- /dev/null +++ b/patches/expo-av/details.md @@ -0,0 +1,26 @@ +# `expo-av` patches + +### [expo-av+15.1.7+001+fix-blank-screen-android.patch](expo-av+15.1.7+001+fix-blank-screen-android.patch) + +- Reason: + + ``` + This patch fixes blank modal after fullscreen video ends on Android. + ``` + +- Upstream PR/issue: The maintainer of the upstream lib isn't willing to accept fix for the lib because it's going to be replaced by another lib `expo-video`, see comment https://github.com/expo/expo/issues/19039#issuecomment-2688369708 +- E/App issue: https://github.com/Expensify/App/issues/53904 +- PR introducing patch: https://github.com/Expensify/App/pull/56302 + + +### [expo-av+15.1.7+002+handle-unsupported-videos-ios.patch](expo-av+15.1.7+002+handle-unsupported-videos-ios.patch) + +- Reason: + + ``` + This patch handles unsupported videos in iOS. + ``` + +- Upstream PR/issue: Probably same reason as the first patch. +- E/App issue: https://github.com/Expensify/App/issues/52673 +- PR introducing patch: https://github.com/Expensify/App/pull/57353 \ No newline at end of file diff --git a/patches/expo-av/expo-av+15.1.7+001+fix-blank-screen-android.patch b/patches/expo-av/expo-av+15.1.7+001+fix-blank-screen-android.patch new file mode 100644 index 000000000000..3b3f30706250 --- /dev/null +++ b/patches/expo-av/expo-av+15.1.7+001+fix-blank-screen-android.patch @@ -0,0 +1,221 @@ +diff --git a/node_modules/expo-av/android/src/main/java/expo/modules/av/player/SimpleExoPlayerData.java b/node_modules/expo-av/android/src/main/java/expo/modules/av/player/SimpleExoPlayerData.java +index 8835a39..87e1964 100644 +--- a/node_modules/expo-av/android/src/main/java/expo/modules/av/player/SimpleExoPlayerData.java ++++ b/node_modules/expo-av/android/src/main/java/expo/modules/av/player/SimpleExoPlayerData.java +@@ -2,6 +2,7 @@ package expo.modules.av.player; + + import android.content.Context; + import android.net.Uri; ++import android.os.Build; + import android.os.Bundle; + import android.os.Looper; + +@@ -63,6 +64,8 @@ class SimpleExoPlayerData extends PlayerData + private boolean mIsLoading = true; + private final Context mReactContext; + ++ private Surface mSurface = null; ++ + SimpleExoPlayerData(final AVManagerInterface avModule, final Context context, final Uri uri, final String overridingExtension, final Map requestHeaders) { + super(avModule, uri, requestHeaders); + mReactContext = context; +@@ -228,6 +231,13 @@ class SimpleExoPlayerData extends PlayerData + + @Override + public void tryUpdateVideoSurface(final Surface surface) { ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ if (mSimpleExoPlayer != null && mSurface != surface) { ++ mSimpleExoPlayer.setVideoSurface(surface); ++ mSurface = surface; ++ } ++ return; ++ } + if (mSimpleExoPlayer != null) { + mSimpleExoPlayer.setVideoSurface(surface); + } +diff --git a/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoTextureView.java b/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoTextureView.java +index 41a5979..b378bf4 100644 +--- a/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoTextureView.java ++++ b/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoTextureView.java +@@ -4,6 +4,7 @@ import android.annotation.SuppressLint; + import android.content.Context; + import android.graphics.Matrix; + import android.graphics.SurfaceTexture; ++import android.os.Build; + import android.util.Pair; + import android.util.Size; + import android.view.Surface; +@@ -20,6 +21,10 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + private boolean mIsAttachedToWindow = false; + private Surface mSurface = null; + private boolean mVisible = true; ++ private int mBufferWidth = 0; ++ private int mBufferHeight = 0; ++ private int mPositionX = 0; ++ private int mPositionY = 0; + + public VideoTextureView(final Context themedReactContext, VideoView videoView) { + super(themedReactContext, null, 0); +@@ -55,6 +60,15 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + setTransform(matrix); + invalidate(); + } ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ float[] values = new float[9]; ++ matrix.getValues(values); ++ mBufferWidth = (int)(getWidth() * values[Matrix.MSCALE_X]); ++ mBufferHeight = (int)(getHeight() * values[Matrix.MSCALE_Y]); ++ mPositionX = (int)(values[Matrix.MTRANS_X]); ++ mPositionY = (int)(values[Matrix.MTRANS_Y]); ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } + } + } + +@@ -69,6 +83,9 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + onVisibilityChanged(this, View.INVISIBLE); + onVisibilityChanged(this, View.VISIBLE); + } ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } + } + + // TextureView +@@ -76,7 +93,12 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + @Override + public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int width, final int height) { + mSurface = new Surface(surfaceTexture); +- mVideoView.tryUpdateVideoSurface(mSurface); ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mVideoView.tryUpdateVideoSurface(); ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } else { ++ mVideoView.tryUpdateVideoSurface(mSurface); ++ } + } + + @Override +@@ -86,13 +108,17 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { +- // no-op ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { + mSurface = null; +- mVideoView.tryUpdateVideoSurface(null); ++ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { ++ mVideoView.tryUpdateVideoSurface(null); ++ } + return true; + } + +@@ -108,11 +134,19 @@ public class VideoTextureView extends TextureView implements TextureView.Surface + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mIsAttachedToWindow = true; +- mVideoView.tryUpdateVideoSurface(mSurface); ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mVideoView.tryUpdateVideoSurface(); ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } else { ++ mVideoView.tryUpdateVideoSurface(mSurface); ++ } + } + + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + mVisible = visibility == View.VISIBLE; ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mVideoView.updateSurfaceView(mVisible, mBufferWidth, mBufferHeight, mPositionX, mPositionY); ++ } + } + } +diff --git a/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoView.java b/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoView.java +index 1acb32b..6bac2bf 100644 +--- a/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoView.java ++++ b/node_modules/expo-av/android/src/main/java/expo/modules/av/video/VideoView.java +@@ -2,10 +2,14 @@ package expo.modules.av.video; + + import android.annotation.SuppressLint; + import android.content.Context; ++import android.os.Build; + import android.os.Bundle; + import android.util.Pair; ++import android.view.Gravity; + import android.view.MotionEvent; + import android.view.Surface; ++import android.view.SurfaceControl; ++import android.view.SurfaceView; + import android.widget.FrameLayout; + + import androidx.annotation.NonNull; +@@ -62,6 +66,10 @@ public class VideoView extends FrameLayout implements AudioEventHandler, Fullscr + private boolean mShouldShowFullscreenPlayerOnLoad = false; + private FullscreenVideoPlayerPresentationChangeProgressListener mFullscreenVideoPlayerPresentationOnLoadChangeListener = null; + ++ private Surface mSurface; ++ private SurfaceControl mSurfaceControl; ++ private SurfaceView mSurfaceView; ++ + public VideoView(@NonNull Context context, VideoViewWrapper videoViewWrapper, AppContext appContext) { + super(context); + +@@ -70,6 +78,13 @@ public class VideoView extends FrameLayout implements AudioEventHandler, Fullscr + mAVModule = appContext.getLegacyModuleRegistry().getModule(AVManagerInterface.class); + mAVModule.registerVideoViewForAudioLifecycle(this); + ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mSurfaceView = new SurfaceView(context); ++ mSurfaceControl = new SurfaceControl.Builder().setName("ExpoAVSurfaceControl").setBufferSize(0, 0).build(); ++ mSurface = new Surface(mSurfaceControl); ++ addView(mSurfaceView); ++ } ++ + mVideoTextureView = new VideoTextureView(context, this); + addView(mVideoTextureView, generateDefaultLayoutParams()); + +@@ -387,7 +402,11 @@ public class VideoView extends FrameLayout implements AudioEventHandler, Fullscr + mVideoTextureView.scaleVideoSize(mPlayerData.getVideoWidthHeight(), mResizeMode); + + if (mVideoTextureView.isAttachedToWindow()) { +- mPlayerData.tryUpdateVideoSurface(mVideoTextureView.getSurface()); ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ++ mPlayerData.tryUpdateVideoSurface(mSurface); ++ } else { ++ mPlayerData.tryUpdateVideoSurface(mVideoTextureView.getSurface()); ++ } + } + + if (promise != null) { +@@ -477,6 +496,24 @@ public class VideoView extends FrameLayout implements AudioEventHandler, Fullscr + } + } + ++ public void tryUpdateVideoSurface() { ++ if (mPlayerData != null) { ++ mPlayerData.tryUpdateVideoSurface(mSurface); ++ } ++ } ++ ++ public void updateSurfaceView(boolean visible, int bufferWidth, int bufferHeight, int positionX, int positionY) { ++ new SurfaceControl.Transaction() ++ .reparent(mSurfaceControl, mSurfaceView.getSurfaceControl()) ++ .setBufferSize(mSurfaceControl, bufferWidth, bufferHeight) ++ .setPosition(mSurfaceControl, 0, 0) ++ .setVisibility(mSurfaceControl, visible && bufferWidth > 0 && bufferHeight > 0) ++ .apply(); ++ mSurfaceView.layout(positionX, positionY, positionX + Math.max(bufferWidth, 1), positionY + Math.max(bufferHeight, 1)); ++ mSurfaceView.setLayoutParams(new FrameLayout.LayoutParams(Math.max(bufferWidth, 1), Math.max(bufferHeight, 1), Gravity.CENTER)); ++ mSurfaceView.requestLayout(); ++ } ++ + // AudioEventHandler + + @Override diff --git a/patches/expo-av/expo-av+15.1.7+002+handle-unsupported-videos-ios.patch b/patches/expo-av/expo-av+15.1.7+002+handle-unsupported-videos-ios.patch new file mode 100644 index 000000000000..b5e27e027e35 --- /dev/null +++ b/patches/expo-av/expo-av+15.1.7+002+handle-unsupported-videos-ios.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/expo-av/ios/EXAV/EXAVPlayerData.m b/node_modules/expo-av/ios/EXAV/EXAVPlayerData.m +index 99dc808..01e4bb9 100644 +--- a/node_modules/expo-av/ios/EXAV/EXAVPlayerData.m ++++ b/node_modules/expo-av/ios/EXAV/EXAVPlayerData.m +@@ -158,8 +158,16 @@ NSString *const EXAVPlayerDataObserverMetadataKeyPath = @"timedMetadata"; + // unless we preload, the asset will not necessarily load the duration by the time we try to play it. + // http://stackoverflow.com/questions/20581567/avplayer-and-avfoundationerrordomain-code-11819 + EX_WEAKIFY(self); +- [avAsset loadValuesAsynchronouslyForKeys:@[ @"duration" ] completionHandler:^{ ++ [avAsset loadValuesAsynchronouslyForKeys:@[ @"isPlayable", @"duration" ] completionHandler:^{ + EX_ENSURE_STRONGIFY(self); ++ NSError *error = nil; ++ AVKeyValueStatus status = [avAsset statusOfValueForKey:@"isPlayable" error:&error]; ++ ++ if (status == AVKeyValueStatusLoaded && !avAsset.isPlayable) { ++ NSString *errorMessage = @"Load encountered an error: [AVAsset isPlayable:] returned false. The asset does not contains a playable content or is not supported by the device."; ++ [self _finishLoadWithError:errorMessage]; ++ return; ++ } + + // We prepare three items for AVQueuePlayer, so when the first finishes playing, + // second can start playing and the third can start preparing to play. diff --git a/patches/expo-video/details.md b/patches/expo-video/details.md deleted file mode 100644 index 84dc26c37428..000000000000 --- a/patches/expo-video/details.md +++ /dev/null @@ -1,13 +0,0 @@ -# `expo-video` patches - -### [expo-video+3.0.11+001+prevent_double_pause.patch](expo-video+3.0.11+001+prevent_double_pause.patch) - -- Reason: - - ``` - This patch edits the onPause eventListener in expo-video's videoPlayer. It still pauses all mounted videos, but now excludes the video that invoked the listener, preventing it from being paused twice. - ``` - -- Upstream PR/issue: https://github.com/expo/expo/issues/40743 -- E/App issue: ๐Ÿ›‘ -- PR Introducing Patch: https://github.com/Expensify/App/pull/66793 diff --git a/patches/expo-video/expo-video+3.0.11+001+prevent_double_pause.patch b/patches/expo-video/expo-video+3.0.11+001+prevent_double_pause.patch deleted file mode 100644 index d1a996d78051..000000000000 --- a/patches/expo-video/expo-video+3.0.11+001+prevent_double_pause.patch +++ /dev/null @@ -1,24 +0,0 @@ - -diff --git a/node_modules/expo-video/build/VideoPlayer.web.js b/node_modules/expo-video/build/VideoPlayer.web.js -index 2c7224a..16df18b 100644 ---- a/node_modules/expo-video/build/VideoPlayer.web.js -+++ b/node_modules/expo-video/build/VideoPlayer.web.js -@@ -305,14 +305,16 @@ export default class VideoPlayerWeb extends globalThis.expo.SharedObject { - mountedVideo.play(); - }); - }; -- video.onpause = () => { -+ video.onpause = (e) => { - this._emitOnce(video, 'playingChange', { - isPlaying: false, - oldIsPlaying: this.playing, - }); - this.playing = false; - this._mountedVideos.forEach((mountedVideo) => { -- mountedVideo.pause(); -+ if (e.target !== mountedVideo) { -+ mountedVideo.pause(); -+ } - }); - }; - video.onvolumechange = () => { diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx index 1a07fbc49dcf..560be3304cff 100644 --- a/src/components/AttachmentPreview.tsx +++ b/src/components/AttachmentPreview.tsx @@ -1,7 +1,6 @@ import {Str} from 'expensify-common'; -import type {VideoThumbnail} from 'expo-video'; -import {useVideoPlayer} from 'expo-video'; -import React, {useEffect, useMemo, useState} from 'react'; +import {ResizeMode, Video} from 'expo-av'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import {cleanFileName, getFileName} from '@libs/fileDownload/FileUtils'; @@ -38,12 +37,6 @@ function AttachmentPreview({source, aspectRatio = 1, onPress, onLoadError}: Atta return cleanFileName(rawFileName); }, [source]); - const [thumbnail, setThumbnail] = useState(null); - const videoPlayer = useVideoPlayer({uri: source}); - useEffect(() => { - videoPlayer.generateThumbnailsAsync(1).then((thumbnails) => setThumbnail(thumbnails.at(0) ?? null)); - }, [videoPlayer]); - if (typeof source === 'string' && Str.isVideo(source)) { return ( - {!!thumbnail && ( - - )} +