diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index bb10ba5c5..e2c3175bb 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,441 +1,495 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useState } from 'react'; -import { View, Text, TouchableWithoutFeedback } from 'react-native'; +import { View, Text } from 'react-native'; +import { + TapGestureHandler, + TouchableWithoutFeedback, +} from 'react-native-gesture-handler'; import * as Progress from 'react-native-progress'; import Animated from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import Video from 'react-native-video'; import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils'; import type { MediaInfo } from 'lib/types/media-types'; import type { ChatMultimediaMessageInfoItem } from '../chat/multimedia-message.react'; import Button from '../components/button.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors'; import { useStyles } from '../themes/colors'; import type { VerticalBounds, LayoutCoordinates } from '../types/layout-types'; +import { gestureJustEnded, animateTowards } from '../utils/animation-utils'; import { formatDuration } from './video-utils'; /* eslint-disable import/no-named-as-default-member */ const { Extrapolate, + cond, set, add, sub, multiply, divide, + not, max, min, abs, interpolate, useValue, + event, } = Animated; export type VideoPlaybackModalParams = {| +presentedFrom: string, +mediaInfo: MediaInfo, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +item: ChatMultimediaMessageInfoItem, |}; type Props = {| +navigation: AppNavigationProp<'VideoPlaybackModal'>, +route: NavigationRoute<'VideoPlaybackModal'>, |}; function VideoPlaybackModal(props: Props) { const { mediaInfo } = props.route.params; + + /* ===== START FADE CONTROL ANIMATION ===== */ + + const singleTapState = useValue(-1); + const singleTapX = useValue(0); + const singleTapY = useValue(0); + + const singleTapEvent = React.useCallback( + event([ + { + nativeEvent: { + state: singleTapState, + x: singleTapX, + y: singleTapY, + }, + }, + ]), + [], + ); + + const controlsShowing = useValue(1); + const controlsOpacity = React.useMemo( + () => + animateTowards( + [ + cond( + gestureJustEnded(singleTapState), + set(controlsShowing, not(controlsShowing)), + ), + controlsShowing, + ], + 150, + ), + [singleTapState, controlsShowing], + ); + + /* ===== END FADE CONTROL ANIMATION ===== */ + const mediaDimensions = mediaInfo.dimensions; const screenDimensions = useSelector(derivedDimensionsInfoSelector); const frame = React.useMemo( () => ({ width: screenDimensions.width, height: screenDimensions.safeAreaHeight, }), [screenDimensions], ); const mediaDisplayDimensions = React.useMemo(() => { let { height: maxHeight, width: maxWidth } = frame; if (maxHeight > maxWidth) { maxHeight -= 100; } else { maxWidth -= 100; } if ( mediaDimensions.height < maxHeight && mediaDimensions.width < maxWidth ) { return mediaDimensions; } const heightRatio = maxHeight / mediaDimensions.height; const widthRatio = maxWidth / mediaDimensions.width; if (heightRatio < widthRatio) { return { height: maxHeight, width: mediaDimensions.width * heightRatio, }; } else { return { width: maxWidth, height: mediaDimensions.height * widthRatio, }; } }, [frame, mediaDimensions]); const centerX = useValue(frame.width / 2); const centerY = useValue(frame.height / 2 + screenDimensions.topInset); const frameWidth = useValue(frame.width); const frameHeight = useValue(frame.height); const imageWidth = useValue(mediaDisplayDimensions.width); const imageHeight = useValue(mediaDisplayDimensions.height); React.useEffect(() => { const { width: frameW, height: frameH } = frame; const { topInset } = screenDimensions; frameWidth.setValue(frameW); frameHeight.setValue(frameH); centerX.setValue(frameW / 2); centerY.setValue(frameH / 2 + topInset); const { width, height } = mediaDisplayDimensions; imageWidth.setValue(width); imageHeight.setValue(height); }, [ screenDimensions, frame, mediaDisplayDimensions, frameWidth, frameHeight, centerX, centerY, imageWidth, imageHeight, ]); const left = sub(centerX, divide(imageWidth, 2)); const top = sub(centerY, divide(imageHeight, 2)); const { initialCoordinates } = props.route.params; const initialScale = divide(initialCoordinates.width, imageWidth); const initialTranslateX = sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ); const initialTranslateY = sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ); // The all-important outputs const curScale = useValue(1); const curX = useValue(0); const curY = useValue(0); const curBackdropOpacity = useValue(1); const progressiveOpacity = max( min( sub(1, abs(divide(curX, frameWidth))), sub(1, abs(divide(curY, frameHeight))), ), 0, ); const updates = [set(curBackdropOpacity, progressiveOpacity)]; const updatedScale = [updates, curScale]; const updatedCurX = [updates, curX]; const updatedCurY = [updates, curY]; const updatedBackdropOpacity = [updates, curBackdropOpacity]; const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext'); const navigationProgress = overlayContext.position; const reverseNavigationProgress = sub(1, navigationProgress); const scale = add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ); const x = add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ); const y = add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ); const backdropOpacity = multiply(navigationProgress, updatedBackdropOpacity); const imageContainerOpacity = interpolate(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const { verticalBounds } = props.route.params; const videoContainerStyle = React.useMemo(() => { const { height, width } = mediaDisplayDimensions; const { height: frameH, width: frameW } = frame; return { height, width, marginTop: (frameH - height) / 2 + screenDimensions.topInset - verticalBounds.y, marginLeft: (frameW - width) / 2, opacity: imageContainerOpacity, transform: [{ translateX: x }, { translateY: y }, { scale: scale }], }; }, [ mediaDisplayDimensions, frame, screenDimensions.topInset, verticalBounds.y, imageContainerOpacity, x, y, scale, ]); const styles = useStyles(unboundStyles); const [paused, setPaused] = useState(false); const [percentElapsed, setPercentElapsed] = useState(0); - const [controlsVisible, setControlsVisible] = useState(true); const [spinnerVisible, setSpinnerVisible] = useState(true); const [timeElapsed, setTimeElapsed] = useState('0:00'); const [totalDuration, setTotalDuration] = useState('0:00'); const videoRef = React.useRef(); const backgroundedOrInactive = useIsAppBackgroundedOrInactive(); React.useEffect(() => { if (backgroundedOrInactive) { setPaused(true); - setControlsVisible(true); + controlsShowing.setValue(1); } - }, [backgroundedOrInactive]); + }, [backgroundedOrInactive, controlsShowing]); const { navigation, route: { params: { mediaInfo: { uri: videoUri }, }, }, } = props; const togglePlayback = React.useCallback(() => { setPaused(!paused); }, [paused]); - const togglePlaybackControls = React.useCallback(() => { - setControlsVisible(!controlsVisible); - }, [controlsVisible]); - const resetVideo = React.useCallback(() => { invariant(videoRef.current, 'videoRef.current should be set in resetVideo'); videoRef.current.seek(0); }, []); const progressCallback = React.useCallback((res) => { setTimeElapsed(formatDuration(res.currentTime)); setTotalDuration(formatDuration(res.seekableDuration)); setPercentElapsed( Math.ceil((res.currentTime / res.seekableDuration) * 100), ); }, []); const readyForDisplayCallback = React.useCallback(() => { setSpinnerVisible(false); }, []); const statusBar = overlayContext.isDismissing ? null : (