diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js index 55009a98b..c93e67d0d 100644 --- a/native/media/video-playback-modal.react.js +++ b/native/media/video-playback-modal.react.js @@ -1,755 +1,799 @@ // @flow import Icon from '@expo/vector-icons/MaterialCommunityIcons.js'; import invariant from 'invariant'; import * as React from 'react'; import { useState } from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; +import filesystem from 'react-native-fs'; import { TapGestureHandler } from 'react-native-gesture-handler'; import * as Progress from 'react-native-progress'; import Animated from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Video from 'react-native-video'; import { useIsAppBackgroundedOrInactive } from 'lib/shared/lifecycle-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; +import { decryptMedia } from './encryption-utils.js'; import { formatDuration } from './video-utils.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { derivedDimensionsInfoSelector } from '../selectors/dimensions-selectors.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; import type { NativeMethods } from '../types/react-native.js'; import { gestureJustEnded, animateTowards } from '../utils/animation-utils.js'; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; /* eslint-disable import/no-named-as-default-member */ const { Extrapolate, and, or, block, cond, eq, ceil, call, set, add, sub, multiply, divide, not, max, min, lessThan, greaterThan, abs, interpolateNode, 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): React.Node { const { mediaInfo } = props.route.params; + const { uri, holder, encryptionKey } = mediaInfo; + const [videoSource, setVideoSource] = React.useState( + uri ? { uri } : undefined, + ); + + React.useEffect(() => { + // skip for unencrypted videos + if (!holder || !encryptionKey) { + return; + } + + let isMounted = true; + let uriToDispose; + setVideoSource(undefined); + + const loadDecrypted = async () => { + const { result } = await decryptMedia(holder, encryptionKey, { + destination: 'file', + }); + if (result.success && isMounted) { + uriToDispose = result.uri; + setVideoSource({ uri: result.uri }); + } + }; + loadDecrypted(); + + return () => { + isMounted = false; + if (uriToDispose) { + // remove the temporary file created by decryptMedia + filesystem.unlink(uriToDispose); + } + }; + }, [holder, encryptionKey]); + const closeButtonX = useValue(-1); const closeButtonY = useValue(-1); const closeButtonWidth = useValue(-1); const closeButtonHeight = useValue(-1); const closeButtonRef = React.useRef>(); const closeButton = closeButtonRef.current; const onCloseButtonLayoutCalledRef = React.useRef(false); const onCloseButtonLayout = React.useCallback(() => { onCloseButtonLayoutCalledRef.current = true; }, []); const onCloseButtonLayoutCalled = onCloseButtonLayoutCalledRef.current; React.useEffect(() => { if (!closeButton || !onCloseButtonLayoutCalled) { return; } closeButton.measure((x, y, width, height, pageX, pageY) => { closeButtonX.setValue(pageX); closeButtonY.setValue(pageY); closeButtonWidth.setValue(width); closeButtonHeight.setValue(height); }); }, [ closeButton, onCloseButtonLayoutCalled, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, ]); const footerX = useValue(-1); const footerY = useValue(-1); const footerWidth = useValue(-1); const footerHeight = useValue(-1); const footerRef = React.useRef(); const footer = footerRef.current; const onFooterLayoutCalledRef = React.useRef(false); const onFooterLayout = React.useCallback(() => { onFooterLayoutCalledRef.current = true; }, []); const onFooterLayoutCalled = onFooterLayoutCalledRef.current; React.useEffect(() => { if (!footer || !onFooterLayoutCalled) { return; } footer.measure((x, y, width, height, pageX, pageY) => { footerX.setValue(pageX); footerY.setValue(pageY); footerWidth.setValue(width); footerHeight.setValue(height); }); }, [ footer, onFooterLayoutCalled, footerX, footerY, footerWidth, footerHeight, ]); const controlsShowing = useValue(1); const outsideButtons = React.useCallback( (x, y) => and( or( eq(controlsShowing, 0), lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( eq(controlsShowing, 0), lessThan(x, footerX), greaterThan(x, add(footerX, footerWidth)), lessThan(y, footerY), greaterThan(y, add(footerY, footerHeight)), ), ), [ controlsShowing, closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, footerX, footerY, footerWidth, footerHeight, ], ); /* ===== START FADE CONTROL ANIMATION ===== */ const singleTapState = useValue(-1); const singleTapX = useValue(0); const singleTapY = useValue(0); const singleTapEvent = React.useMemo( () => event([ { nativeEvent: { state: singleTapState, x: singleTapX, y: singleTapY, }, }, ]), [singleTapState, singleTapX, singleTapY], ); const lastTapX = useValue(-1); const lastTapY = useValue(-1); const activeControlsOpacity = React.useMemo( () => animateTowards( block([ cond( and( gestureJustEnded(singleTapState), outsideButtons(lastTapX, lastTapY), ), set(controlsShowing, not(controlsShowing)), ), set(lastTapX, singleTapX), set(lastTapY, singleTapY), controlsShowing, ]), 150, ), [ singleTapState, controlsShowing, outsideButtons, lastTapX, lastTapY, singleTapX, singleTapY, ], ); const [controlsEnabled, setControlsEnabled] = React.useState(true); const enableControls = React.useCallback(() => setControlsEnabled(true), []); const disableControls = React.useCallback( () => setControlsEnabled(false), [], ); const previousOpacityCeiling = useValue(-1); const opacityCeiling = React.useMemo( () => ceil(activeControlsOpacity), [activeControlsOpacity], ); const opacityJustChanged = React.useMemo( () => cond(eq(previousOpacityCeiling, opacityCeiling), 0, [ set(previousOpacityCeiling, opacityCeiling), 1, ]), [previousOpacityCeiling, opacityCeiling], ); const toggleControls = React.useMemo( () => [ cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 0)), call([], disableControls), ), cond( and(eq(opacityJustChanged, 1), eq(opacityCeiling, 1)), call([], enableControls), ), ], [opacityJustChanged, opacityCeiling, disableControls, enableControls], ); /* ===== 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 = React.useMemo( () => sub(centerX, divide(imageWidth, 2)), [centerX, imageWidth], ); const top = React.useMemo( () => sub(centerY, divide(imageHeight, 2)), [centerY, imageHeight], ); const { initialCoordinates } = props.route.params; const initialScale = React.useMemo( () => divide(initialCoordinates.width, imageWidth), [initialCoordinates, imageWidth], ); const initialTranslateX = React.useMemo( () => sub( initialCoordinates.x + initialCoordinates.width / 2, add(left, divide(imageWidth, 2)), ), [initialCoordinates, left, imageWidth], ); const initialTranslateY = React.useMemo( () => sub( initialCoordinates.y + initialCoordinates.height / 2, add(top, divide(imageHeight, 2)), ), [initialCoordinates, top, imageHeight], ); // The all-important outputs const curScale = useValue(1); const curX = useValue(0); const curY = useValue(0); const curBackdropOpacity = useValue(1); const progressiveOpacity = React.useMemo( () => max( min( sub(1, abs(divide(curX, frameWidth))), sub(1, abs(divide(curY, frameHeight))), ), 0, ), [curX, curY, frameWidth, frameHeight], ); const updates = React.useMemo( () => [toggleControls, set(curBackdropOpacity, progressiveOpacity)], [curBackdropOpacity, progressiveOpacity, toggleControls], ); const updatedScale = React.useMemo( () => [updates, curScale], [updates, curScale], ); const updatedCurX = React.useMemo(() => [updates, curX], [updates, curX]); const updatedCurY = React.useMemo(() => [updates, curY], [updates, curY]); const updatedBackdropOpacity = React.useMemo( () => [updates, curBackdropOpacity], [updates, curBackdropOpacity], ); const updatedActiveControlsOpacity = React.useMemo( () => block([updates, activeControlsOpacity]), [updates, activeControlsOpacity], ); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'VideoPlaybackModal should have OverlayContext'); const navigationProgress = overlayContext.position; const reverseNavigationProgress = React.useMemo( () => sub(1, navigationProgress), [navigationProgress], ); const dismissalButtonOpacity = interpolateNode(updatedBackdropOpacity, { inputRange: [0.95, 1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const controlsOpacity = multiply( navigationProgress, dismissalButtonOpacity, updatedActiveControlsOpacity, ); const scale = React.useMemo( () => add( multiply(reverseNavigationProgress, initialScale), multiply(navigationProgress, updatedScale), ), [reverseNavigationProgress, initialScale, navigationProgress, updatedScale], ); const x = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateX), multiply(navigationProgress, updatedCurX), ), [ reverseNavigationProgress, initialTranslateX, navigationProgress, updatedCurX, ], ); const y = React.useMemo( () => add( multiply(reverseNavigationProgress, initialTranslateY), multiply(navigationProgress, updatedCurY), ), [ reverseNavigationProgress, initialTranslateY, navigationProgress, updatedCurY, ], ); const backdropOpacity = React.useMemo( () => multiply(navigationProgress, updatedBackdropOpacity), [navigationProgress, updatedBackdropOpacity], ); const imageContainerOpacity = React.useMemo( () => interpolateNode(navigationProgress, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), [navigationProgress], ); 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 [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); controlsShowing.setValue(1); } }, [backgroundedOrInactive, controlsShowing]); - const { - navigation, - route: { - params: { - mediaInfo: { uri: videoUri }, - }, - }, - } = props; + const { navigation } = props; const togglePlayback = React.useCallback(() => { setPaused(!paused); }, [paused]); 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 : (