diff --git a/src/components/common/helpers/mediaDimensions.ts b/src/components/common/helpers/mediaDimensions.ts index 28d664a87..58743f8a3 100644 --- a/src/components/common/helpers/mediaDimensions.ts +++ b/src/components/common/helpers/mediaDimensions.ts @@ -3,7 +3,7 @@ import { } from '../../../api/types'; import { STICKER_SIZE_INLINE_DESKTOP_FACTOR, STICKER_SIZE_INLINE_MOBILE_FACTOR } from '../../../config'; -import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; +import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../../util/environment'; import windowSize from '../../../util/windowSize'; import { getPhotoInlineDimensions, getVideoDimensions } from '../../../modules/helpers'; @@ -110,10 +110,9 @@ export function getMediaViewerAvailableDimensions(withFooter: boolean, isVideo: const mql = window.matchMedia(MEDIA_VIEWER_MEDIA_QUERY); const { width: windowWidth, height: windowHeight } = windowSize.get(); let occupiedHeightRem = isVideo && mql.matches ? 10 : 8.25; - if (withFooter) { - occupiedHeightRem = mql.matches ? 10 : 15; + if (withFooter && !IS_TOUCH_ENV) { + occupiedHeightRem = mql.matches ? 10 : 12.5; } - return { width: windowWidth, height: windowHeight - occupiedHeightRem * REM, diff --git a/src/components/mediaViewer/MediaViewer.scss b/src/components/mediaViewer/MediaViewer.scss index b2ad92004..1a83519df 100644 --- a/src/components/mediaViewer/MediaViewer.scss +++ b/src/components/mediaViewer/MediaViewer.scss @@ -27,7 +27,7 @@ } body.ghost-animating & { - > .pan-wrapper, > .Transition, > button { + > .pan-wrapper, > button, .MediaViewerContent img, .MediaViewerContent .VideoPlayer { display: none; } } diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 6bfa13dcd..9cdb3ca43 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -121,12 +121,13 @@ const MediaViewer: FC = ({ const isAvatar = Boolean(avatarOwner); /* Navigation */ - const isSingleSlide = Boolean(webPagePhoto || webPageVideo); + const singleMessageId = webPagePhoto || webPageVideo ? messageId : undefined; + const messageIds = useMemo(() => { - return isSingleSlide && messageId - ? [messageId] + return singleMessageId + ? [singleMessageId] : getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia); - }, [isSingleSlide, messageId, chatMessages, collectionIds, isFromSharedMedia]); + }, [singleMessageId, chatMessages, collectionIds, isFromSharedMedia]); const selectedMediaMessageIndex = messageId ? messageIds.indexOf(messageId) : -1; const isFirst = selectedMediaMessageIndex === 0 || selectedMediaMessageIndex === -1; @@ -513,6 +514,7 @@ const MediaViewer: FC = ({ hasFooter={hasFooter} isZoomed={isZoomed} isActive={isActive} + isVideo={isVideo} animationLevel={animationLevel} onClose={close} selectMessage={selectMessage} diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index a7a137700..8241ce6fc 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -176,6 +176,7 @@ const MediaViewerContent: FC = (props) => { if (!message) return undefined; const textParts = renderMessageText(message); const hasFooter = Boolean(textParts); + return (
= (props) => { url={localBlobUrl || fullMediaBlobUrl} isGif={isGif} posterData={bestImageData} - posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)} + posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, false)} loadProgress={loadProgress} fileSize={videoSize!} - isMediaViewerOpen={isOpen} + isMediaViewerOpen={isOpen && isActive} noPlay={!isActive} onClose={onClose} /> ) : renderVideoPreview( bestImageData, - message && calculateMediaViewerDimensions(dimensions!, hasFooter, true), + message && calculateMediaViewerDimensions(dimensions!, hasFooter, false), !IS_SINGLE_COLUMN_LAYOUT && !isProtected, ))} {textParts && ( )} diff --git a/src/components/mediaViewer/MediaViewerFooter.scss b/src/components/mediaViewer/MediaViewerFooter.scss index 0b9e0e5cd..d18e6bd35 100644 --- a/src/components/mediaViewer/MediaViewerFooter.scss +++ b/src/components/mediaViewer/MediaViewerFooter.scss @@ -14,20 +14,24 @@ } @media (max-width: 600px) { - padding-bottom: 4.5rem; background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%); &.is-for-video { opacity: 0; pointer-events: none; + padding-bottom: 5rem; - .video-controls-visible & { + .video-controls-visible &:not(.is-hidden) { opacity: 1; pointer-events: all; } } } + body.ghost-animating & { + opacity: 0; + } + .media-viewer-footer-content { position: relative; max-width: var(--messages-container-width); diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index 9920705f9..a5c9943e2 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -8,13 +8,14 @@ import useDebounce from '../../hooks/useDebounce'; import useForceUpdate from '../../hooks/useForceUpdate'; import { animateNumber, timingFunctions } from '../../util/animation'; import arePropsShallowEqual from '../../util/arePropsShallowEqual'; -import { captureEvents } from '../../util/captureEvents'; -import { IS_TOUCH_ENV } from '../../util/environment'; +import { captureEvents, IOS_SCREEN_EDGE_THRESHOLD, RealTouchEvent } from '../../util/captureEvents'; +import { IS_IOS, IS_TOUCH_ENV } from '../../util/environment'; import { debounce } from '../../util/schedulers'; import MediaViewerContent from './MediaViewerContent'; import './MediaViewerSlides.scss'; +import useTimeout from '../../hooks/useTimeout'; type OwnProps = { messageId?: number; @@ -47,6 +48,7 @@ const DEBOUNCE_ACTIVE = 800; const MAX_ZOOM = 4; const MIN_ZOOM = 0.6; const DOUBLE_TAP_ZOOM = 3; +const CLICK_X_THRESHOLD = 120; let cancelAnimation: Function | undefined; type Transform = { @@ -55,11 +57,10 @@ type Transform = { scale: number; }; -const INITIAL_TRANSFORM = { - x: 0, - y: 0, - scale: 1, -}; +enum SwipeDirection { + Horizontal, + Vertical, +} const MediaViewerSlides: FC = ({ messageId, @@ -77,12 +78,12 @@ const MediaViewerSlides: FC = ({ const containerRef = useRef(null); // eslint-disable-next-line no-null/no-null const activeSlideRef = useRef(null); - const transformRef = useRef(INITIAL_TRANSFORM); - const isSwipingRef = useRef(false); + const transformRef = useRef({ x: 0, y: 0, scale: 1 }); + const swipeDirectionRef = useRef(undefined); const isActiveRef = useRef(true); const [activeMessageId, setActiveMessageId] = useState(messageId); const forceUpdate = useForceUpdate(); - const [isFooterHidden, setIsFooterHidden] = useState(false); + const [isFooterHidden, setIsFooterHidden] = useState(true); const { isZoomed, @@ -94,26 +95,24 @@ const MediaViewerSlides: FC = ({ forceUpdate(); }, [forceUpdate]); - const setIsSwiping = useCallback((value: boolean) => { - isSwipingRef.current = value; - forceUpdate(); - }, [forceUpdate]); - const setIsActive = useCallback((value: boolean) => { isActiveRef.current = value; forceUpdate(); }, [forceUpdate]); const debounceSetMessage = useDebounce(DEBOUNCE_MESSAGE, true); - const debounceSwipe = useDebounce(DEBOUNCE_SWIPE, true); + const debounceSwipeDirection = useDebounce(DEBOUNCE_SWIPE, true); const debounceActive = useDebounce(DEBOUNCE_ACTIVE, true); - const handleToggleFooterVisibility = useCallback(() => { - if (IS_TOUCH_ENV && (isPhoto || isGif) && hasFooter) { - setIsFooterHidden(!isFooterHidden); - } + const handleToggleFooterVisibility = useCallback((e: React.MouseEvent) => { + if (!IS_TOUCH_ENV || !hasFooter || (!isPhoto && !isGif)) return; + if (e.clientX < CLICK_X_THRESHOLD) return; + if (e.clientX > window.innerWidth - CLICK_X_THRESHOLD) return; + setIsFooterHidden(!isFooterHidden); }, [hasFooter, isFooterHidden, isGif, isPhoto]); + useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150); + useEffect(() => { if (!IS_TOUCH_ENV || !containerRef.current || isZoomed || !activeMessageId) { return undefined; @@ -123,7 +122,10 @@ const MediaViewerSlides: FC = ({ x: 0, y: 0, }; - const lastZoomCenter = { x: 0, y: 0 }; + const lastZoomCenter = { + x: 0, + y: 0, + }; const panDelta = { x: 0, y: 0, @@ -134,18 +136,54 @@ const MediaViewerSlides: FC = ({ const setLastGestureTime = debounce(() => { lastGestureTime = Date.now(); }, 500, false, true); + + const changeSlide = (e: MouseEvent) => { + if (transformRef.current.scale !== 1) return false; + let direction = 0; + if ((e as MouseEvent).clientX < CLICK_X_THRESHOLD) { + direction = -1; + } else if ((e as MouseEvent).clientX > window.innerWidth - CLICK_X_THRESHOLD) { + direction = 1; + } + const mId = getMessageId(activeMessageId, direction); + if (mId) { + const offset = (window.innerWidth + SLIDES_GAP) * direction; + transformRef.current.x += offset; + isActiveRef.current = false; + setActiveMessageId(mId); + debounceSetMessage(() => selectMessage(mId)); + debounceActive(() => { + setIsActive(true); + }); + lastTransform = { x: 0, y: 0, scale: 1 }; + cancelAnimation = animateNumber({ + from: transformRef.current.x, + to: 0, + duration: ANIMATION_DURATION, + timing: timingFunctions.easeOutCubic, + onUpdate: (value) => setTransform({ + y: 0, + x: value, + scale: 1, + }), + }); + } + return direction !== 0; + }; + return captureEvents(containerRef.current, { isNotPassive: true, excludedClosestSelector: '.VideoPlayerControls, .MediaViewerFooter', onCapture: (event) => { - // Prevent safari back swipe on mobile - if (event.type === 'touchstart' - && 'pageX' in event - && !(event.pageX > 10 && event.pageX < window.innerWidth - 10)) { - event.preventDefault(); + // Avoid conflicts with swipe-to-back gestures + if (event.type === 'touchstart' && IS_IOS) { + const x = (event as RealTouchEvent).touches[0].pageX; + if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= window.innerWidth - IOS_SCREEN_EDGE_THRESHOLD) { + event.preventDefault(); + } } lastGestureTime = Date.now(); - if (arePropsShallowEqual(transformRef.current, INITIAL_TRANSFORM)) { + if (arePropsShallowEqual(transformRef.current, { x: 0, y: 0, scale: 1 })) { if (!activeSlideRef.current) return; content = activeSlideRef.current.querySelector('img, video'); if (!content) return; @@ -167,7 +205,11 @@ const MediaViewerSlides: FC = ({ lastDragOffset.y = dragOffsetY; const absOffsetX = Math.abs(dragOffsetX); const absOffsetY = Math.abs(dragOffsetY); - const { scale, x, y } = transformRef.current; + const { + scale, + x, + y, + } = transformRef.current; const h = 10; // If user is inactive but is still touching the screen @@ -185,21 +227,25 @@ const MediaViewerSlides: FC = ({ } return; } - // If user is swiping horizontally or horizontal shift is dominant - // we change only horizontal position - if (isSwipingRef.current || Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) { - isSwipingRef.current = true; - isActiveRef.current = false; - setTransform({ - x: dragOffsetX, - y: 0, - scale, - }); - return; + if (swipeDirectionRef.current !== SwipeDirection.Vertical) { + // If user is swiping horizontally or horizontal shift is dominant + // we change only horizontal position + if (swipeDirectionRef.current === SwipeDirection.Horizontal + || Math.abs(x) > h || (absOffsetX > h && absOffsetY < h)) { + swipeDirectionRef.current = SwipeDirection.Horizontal; + isActiveRef.current = false; + setTransform({ + x: dragOffsetX, + y: 0, + scale, + }); + return; + } } - if (isSwipingRef.current) return; // If vertical shift is dominant we change only vertical position - if (Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) { + if (swipeDirectionRef.current === SwipeDirection.Vertical + || Math.abs(y) > h || (absOffsetY > h && absOffsetX < h)) { + swipeDirectionRef.current = SwipeDirection.Vertical; setTransform({ x: 0, y: dragOffsetY, @@ -240,14 +286,29 @@ const MediaViewerSlides: FC = ({ scale, }); }, + onClick(e) { + if (changeSlide(e as MouseEvent)) { + e.preventDefault(); + e.stopPropagation(); + } + }, onDoubleClick(e, { centerX, centerY, }) { + if (changeSlide(e as MouseEvent)) { + e.preventDefault(); + e.stopPropagation(); + return undefined; + } // Calculate how much we need to shift the image to keep the zoom center at the same position const scaleOffsetX = (centerX - DOUBLE_TAP_ZOOM * centerX); const scaleOffsetY = (centerY - DOUBLE_TAP_ZOOM * centerY); - const { scale, x, y } = transformRef.current; + const { + scale, + x, + y, + } = transformRef.current; if (scale === 1) { if (x !== 0 || y !== 0) return undefined; lastTransform = { @@ -256,7 +317,11 @@ const MediaViewerSlides: FC = ({ scale: DOUBLE_TAP_ZOOM, }; } else { - lastTransform = { x: 0, y: 0, scale: 1 }; + lastTransform = { + x: 0, + y: 0, + scale: 1, + }; } return animateNumber({ from: [x, y, scale], @@ -273,11 +338,22 @@ const MediaViewerSlides: FC = ({ onRelease: () => { const absX = Math.abs(transformRef.current.x); const absY = Math.abs(transformRef.current.y); - const { scale, x, y } = transformRef.current; + const { + scale, + x, + y, + } = transformRef.current; + + debounceSwipeDirection(() => { + swipeDirectionRef.current = undefined; + }); + debounceActive(() => { + setIsActive(true); + }); // If scale is less than 1 we need to bounce back if (scale < 1) { - lastTransform = INITIAL_TRANSFORM; + lastTransform = { x: 0, y: 0, scale: 1 }; return animateNumber({ from: [x, y, scale], to: [0, 0, 1], @@ -292,7 +368,11 @@ const MediaViewerSlides: FC = ({ } if (scale > 1) { if (!content || !initialContentRect) { - lastTransform = { x, y, scale }; + lastTransform = { + x, + y, + scale, + }; return undefined; } // Get current content boundaries @@ -355,7 +435,11 @@ const MediaViewerSlides: FC = ({ }); return undefined; } - lastTransform = { x, y, scale }; + lastTransform = { + x, + y, + scale, + }; if (absY >= SWIPE_Y_THRESHOLD) return onClose(); // Bounce back if vertical swipe is below threshold if (absY > 0) { @@ -387,8 +471,6 @@ const MediaViewerSlides: FC = ({ setActiveMessageId(mId); debounceSetMessage(() => selectMessage(mId)); } - debounceSwipe(() => setIsSwiping(false)); - debounceActive(() => setIsActive(true)); // Then we always return to the original position cancelAnimation = animateNumber({ from: transformRef.current.x, @@ -411,7 +493,6 @@ const MediaViewerSlides: FC = ({ setTransform, getMessageId, activeMessageId, - setIsSwiping, setIsActive, ]); diff --git a/src/components/mediaViewer/SlideTransition.tsx b/src/components/mediaViewer/SlideTransition.tsx index ebecd8430..17f00e832 100644 --- a/src/components/mediaViewer/SlideTransition.tsx +++ b/src/components/mediaViewer/SlideTransition.tsx @@ -5,16 +5,7 @@ import { IS_TOUCH_ENV } from '../../util/environment'; import Transition, { TransitionProps } from '../ui/Transition'; const SlideTransition: FC = ({ children, ...props }) => { - if (IS_TOUCH_ENV) { - // Return dummy container to keep existing DOM structure, needed to preserve ghost animation - return ( -
-
- {children(true, true, 1)} -
-
- ); - } + if (IS_TOUCH_ENV) return children(true, true, 1); // eslint-disable-next-line react/jsx-props-no-spreading return {children}; }; diff --git a/src/components/mediaViewer/VideoPlayer.scss b/src/components/mediaViewer/VideoPlayer.scss index 45bdbf813..4d893f9ae 100644 --- a/src/components/mediaViewer/VideoPlayer.scss +++ b/src/components/mediaViewer/VideoPlayer.scss @@ -49,12 +49,6 @@ @media (max-height: 640px) { max-height: calc(100vh - 10rem); } - @at-root .has-footer #{&} { - max-height: calc(100vh - 15rem); - @media (max-height: 640px) { - max-height: calc(100vh - 10rem); - } - } } .play-button { diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index a2c876f48..70cc24ea1 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -129,10 +129,6 @@ const VideoPlayer: FC = ({ const toggleControls = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setIsControlsVisible(!isControlsVisible); - if (!isControlsVisible) { - videoRef.current!.pause(); - setIsPlayed(false); - } }, [isControlsVisible]); useEffect(() => { @@ -210,7 +206,7 @@ const VideoPlayer: FC = ({ isFullscreenSupported={Boolean(setFullscreen)} isFullscreen={isFullscreen} fileSize={fileSize} - duration={videoRef.current ? videoRef.current.duration : 0} + duration={videoRef.current ? videoRef.current.duration || 0 : 0} isForceVisible={isControlsVisible} isForceMobileVersion={posterSize && posterSize.width < MOBILE_VERSION_CONTROL_WIDTH} onSeek={handleSeek} diff --git a/src/components/mediaViewer/VideoPlayerControls.scss b/src/components/mediaViewer/VideoPlayerControls.scss index 437934c08..8c61069c3 100644 --- a/src/components/mediaViewer/VideoPlayerControls.scss +++ b/src/components/mediaViewer/VideoPlayerControls.scss @@ -8,6 +8,9 @@ padding-top: .625rem; font-size: .875rem; background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%); + transition: opacity .15s; + opacity: 0; + pointer-events: none; #MediaViewer.zoomed & { display: none; @@ -20,6 +23,11 @@ z-index: var(--z-media-viewer); } + &.active { + opacity: 1; + pointer-events: all; + } + &.mobile { .player-file-size { position: static; diff --git a/src/components/mediaViewer/VideoPlayerControls.tsx b/src/components/mediaViewer/VideoPlayerControls.tsx index 4aeccd47e..359e6e3ee 100644 --- a/src/components/mediaViewer/VideoPlayerControls.tsx +++ b/src/components/mediaViewer/VideoPlayerControls.tsx @@ -1,6 +1,7 @@ import React, { FC, useState, useEffect, useRef, useCallback, } from '../../lib/teact/teact'; +import buildClassName from '../../util/buildClassName'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import { formatMediaDuration } from '../../util/dateFormat'; @@ -117,12 +118,13 @@ const VideoPlayerControls: FC = ({ }); }, [isVisible, handleStartSeek, handleSeek, handleStopSeek]); - if (!isVisible && !isForceVisible) { - return undefined; - } + const isActive = isVisible || isForceVisible; return ( -
+
{renderSeekLine(currentTime, duration, bufferedProgress, seekerRef)}