diff --git a/src/components/VideoPlayerContexts/PlaybackContext.tsx b/src/components/VideoPlayerContexts/PlaybackContext.tsx index d926c52e0f549..0c456940407be 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext.tsx +++ b/src/components/VideoPlayerContexts/PlaybackContext.tsx @@ -1,17 +1,56 @@ -import type {NavigationState} from '@react-navigation/native'; +import type {NavigationState, Route} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import type {AVPlaybackStatus, AVPlaybackStatusToSet} from 'expo-av'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types'; import usePrevious from '@hooks/usePrevious'; +import getAttachmentDetails from '@libs/fileDownload/getAttachmentDetails'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; +import {getAllReportActions, getReportActionHtml} from '@libs/ReportActionsUtils'; +import {getReportOrDraftReport, isChatThread} from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; +import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {PlaybackContext, StatusCallback} from './types'; const Context = React.createContext(null); +type SearchRoute = Omit, 'key'> | undefined; +type MoneyRequestReportState = { + params: SearchFullscreenNavigatorParamList[typeof SCREENS.SEARCH.MONEY_REQUEST_REPORT]; +} & SearchRoute; + +function isMoneyRequestReportRouteWithReportIDInParams(route: SearchRoute): route is MoneyRequestReportState { + return !!route && !!route.params && route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT && 'reportID' in route.params; +} + +function findUrlInReportOrAncestorAttachments(currentReport: OnyxEntry, url: string | null): string | undefined { + const {parentReportID, reportID} = currentReport ?? {}; + + const reportActions = getAllReportActions(reportID); + const hasUrlInAttachments = Object.values(reportActions).some((action) => { + const {sourceURL, previewSourceURL} = getAttachmentDetails(getReportActionHtml(action)); + return sourceURL === url || previewSourceURL === url; + }); + + if (hasUrlInAttachments) { + return reportID; + } + + if (parentReportID) { + const parentReport = getReportOrDraftReport(parentReportID); + return findUrlInReportOrAncestorAttachments(parentReport, url); + } + + return undefined; +} + function PlaybackContextProvider({children}: ChildrenProps) { const [currentlyPlayingURL, setCurrentlyPlayingURL] = useState(null); const [currentlyPlayingURLReportID, setCurrentlyPlayingURLReportID] = useState(); @@ -58,14 +97,20 @@ function PlaybackContextProvider({children}: ChildrenProps) { */ const updateCurrentPlayingReportID = useCallback( (state: NavigationState) => { - if (!isReportTopmostSplitNavigator()) { - setCurrentReportID(undefined); - return; + const focusedRoute = findFocusedRoute(state); + let {reportID} = getReportOrDraftReport(Navigation.getTopmostReportId()) ?? {}; + + // We need to handle a case where a report is selected via search and is therefore not a topmost report, + // but we still want to be able to play videos in it + if (isMoneyRequestReportRouteWithReportIDInParams(focusedRoute)) { + reportID = focusedRoute.params.reportID; } - const reportID = Navigation.getTopmostReportId(state); + + reportID = Navigation.getActiveRouteWithoutParams() === `/${ROUTES.ATTACHMENTS.route}` ? prevCurrentReportID : reportID; + setCurrentReportID(reportID); }, - [setCurrentReportID], + [setCurrentReportID, prevCurrentReportID], ); const updateCurrentlyPlayingURL = useCallback( @@ -73,10 +118,22 @@ function PlaybackContextProvider({children}: ChildrenProps) { if (currentlyPlayingURL && url !== currentlyPlayingURL) { pauseVideo(); } - setCurrentlyPlayingURLReportID(currentReportID); + + // Used for /attachment route + const topMostReport = getReportOrDraftReport(Navigation.getTopmostReportId()); + const reportIDFromUrlParams = new URLSearchParams(Navigation.getActiveRoute()).get('reportID') ?? undefined; + const attachmentReportID = Navigation.getActiveRouteWithoutParams() === `/${ROUTES.ATTACHMENTS.route}` ? prevCurrentReportID ?? reportIDFromUrlParams : undefined; + const reportIDWithUrl = isChatThread(topMostReport) ? findUrlInReportOrAncestorAttachments(topMostReport, url) : undefined; + + // - if it is a chat thread, use chat thread ID or any ascentor ID since the video could have originally been sent on report many levels up + // - report ID in which we are currently, if it is not a chat thread + // - if it is an attachment route, then we take report ID from the URL params + const currentPlayReportID = [attachmentReportID, reportIDWithUrl, currentReportID].find((id) => id !== undefined); + + setCurrentlyPlayingURLReportID(currentPlayReportID); setCurrentlyPlayingURL(url); }, - [currentlyPlayingURL, currentReportID, pauseVideo], + [currentlyPlayingURL, currentReportID, prevCurrentReportID, pauseVideo], ); const shareVideoPlayerElements = useCallback( diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index 3831bb117fa36..cdf28c208f297 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -1,15 +1,18 @@ +import {useNavigation} from '@react-navigation/native'; import type {VideoReadyForDisplayEvent} from 'expo-av'; import React, {useEffect, useState} from 'react'; -import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import VideoPlayer from '@components/VideoPlayer'; import IconButton from '@components/VideoPlayer/IconButton'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; +import useFirstRenderRoute from '@hooks/useFirstRenderRoute'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; +import ROUTES from '@src/ROUTES'; import VideoPlayerThumbnail from './VideoPlayerThumbnail'; type VideoDimensions = { @@ -51,6 +54,10 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi const [isThumbnail, setIsThumbnail] = useState(true); const [measuredDimensions, setMeasuredDimensions] = useState(videoDimensions); const {thumbnailDimensionsStyles} = useThumbnailDimensions(measuredDimensions.width, measuredDimensions.height); + const navigation = useNavigation(); + + // We want to play the video only when the user is on the page where it was rendered + const firstRenderRoute = useFirstRenderRoute([`/${ROUTES.ATTACHMENTS.route}`]); // `onVideoLoaded` is passed to VideoPlayerPreview's `Video` element which is displayed only on web. // VideoReadyForDisplayEvent type is lacking srcElement, that's why it's added here @@ -66,11 +73,15 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi }; useEffect(() => { - if (videoUrl !== currentlyPlayingURL || reportID !== currentlyPlayingURLReportID) { + return navigation.addListener('blur', () => !firstRenderRoute.isFocused && setIsThumbnail(true)); + }, [navigation, firstRenderRoute]); + + useEffect(() => { + if (videoUrl !== currentlyPlayingURL || reportID !== currentlyPlayingURLReportID || !firstRenderRoute.isFocused) { return; } setIsThumbnail(false); - }, [currentlyPlayingURL, currentlyPlayingURLReportID, updateCurrentlyPlayingURL, videoUrl, reportID]); + }, [currentlyPlayingURL, currentlyPlayingURLReportID, updateCurrentlyPlayingURL, videoUrl, reportID, firstRenderRoute]); return ( diff --git a/src/hooks/useFirstRenderRoute.ts b/src/hooks/useFirstRenderRoute.ts new file mode 100644 index 0000000000000..f64629f659482 --- /dev/null +++ b/src/hooks/useFirstRenderRoute.ts @@ -0,0 +1,23 @@ +import {useEffect, useRef} from 'react'; +import Navigation from '@navigation/Navigation'; + +function useFirstRenderRoute(focusExceptionRoutes?: string[]) { + const initialRoute = useRef(null); + + useEffect(() => { + initialRoute.current = Navigation.getActiveRouteWithoutParams(); + }, []); + + return { + get isFocused() { + const activeRoute = Navigation.getActiveRouteWithoutParams(); + const allRoutesToConsider = [initialRoute.current, ...(focusExceptionRoutes ?? [])]; + return allRoutesToConsider.includes(activeRoute); + }, + get value() { + return initialRoute.current; + }, + }; +} + +export default useFirstRenderRoute;