Media Viewer: Various improvements and fixes (#1657)

This commit is contained in:
Alexander Zinchuk 2022-01-25 03:24:20 +01:00
parent a1885d5e29
commit 92f15c200b
15 changed files with 203 additions and 105 deletions

View File

@ -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,

View File

@ -27,7 +27,7 @@
}
body.ghost-animating & {
> .pan-wrapper, > .Transition, > button {
> .pan-wrapper, > button, .MediaViewerContent img, .MediaViewerContent .VideoPlayer {
display: none;
}
}

View File

@ -121,12 +121,13 @@ const MediaViewer: FC<StateProps> = ({
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<StateProps> = ({
hasFooter={hasFooter}
isZoomed={isZoomed}
isActive={isActive}
isVideo={isVideo}
animationLevel={animationLevel}
onClose={close}
selectMessage={selectMessage}

View File

@ -176,6 +176,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
if (!message) return undefined;
const textParts = renderMessageText(message);
const hasFooter = Boolean(textParts);
return (
<div
className={`MediaViewerContent ${hasFooter ? 'has-footer' : ''}`}
@ -192,23 +193,23 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (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 && (
<MediaViewerFooter
text={textParts}
onClick={onFooterClick}
isHidden={isFooterHidden && (!isVideo || isGif)}
isHidden={isFooterHidden}
isForVideo={isVideo && !isGif}
/>
)}

View File

@ -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);

View File

@ -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<OwnProps> = ({
messageId,
@ -77,12 +78,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const activeSlideRef = useRef<HTMLDivElement>(null);
const transformRef = useRef<Transform>(INITIAL_TRANSFORM);
const isSwipingRef = useRef(false);
const transformRef = useRef<Transform>({ x: 0, y: 0, scale: 1 });
const swipeDirectionRef = useRef<SwipeDirection | undefined>(undefined);
const isActiveRef = useRef(true);
const [activeMessageId, setActiveMessageId] = useState<number | undefined>(messageId);
const forceUpdate = useForceUpdate();
const [isFooterHidden, setIsFooterHidden] = useState<boolean>(false);
const [isFooterHidden, setIsFooterHidden] = useState<boolean>(true);
const {
isZoomed,
@ -94,26 +95,24 @@ const MediaViewerSlides: FC<OwnProps> = ({
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<HTMLDivElement, 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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
}
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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
}
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<OwnProps> = ({
});
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<OwnProps> = ({
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<OwnProps> = ({
setTransform,
getMessageId,
activeMessageId,
setIsSwiping,
setIsActive,
]);

View File

@ -5,16 +5,7 @@ import { IS_TOUCH_ENV } from '../../util/environment';
import Transition, { TransitionProps } from '../ui/Transition';
const SlideTransition: FC<TransitionProps> = ({ children, ...props }) => {
if (IS_TOUCH_ENV) {
// Return dummy container to keep existing DOM structure, needed to preserve ghost animation
return (
<div className="Transition">
<div className="Transition__slide--active">
{children(true, true, 1)}
</div>
</div>
);
}
if (IS_TOUCH_ENV) return children(true, true, 1);
// eslint-disable-next-line react/jsx-props-no-spreading
return <Transition {...props}>{children}</Transition>;
};

View File

@ -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 {

View File

@ -129,10 +129,6 @@ const VideoPlayer: FC<OwnProps> = ({
const toggleControls = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
setIsControlsVisible(!isControlsVisible);
if (!isControlsVisible) {
videoRef.current!.pause();
setIsPlayed(false);
}
}, [isControlsVisible]);
useEffect(() => {
@ -210,7 +206,7 @@ const VideoPlayer: FC<OwnProps> = ({
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}

View File

@ -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;

View File

@ -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<IProps> = ({
});
}, [isVisible, handleStartSeek, handleSeek, handleStopSeek]);
if (!isVisible && !isForceVisible) {
return undefined;
}
const isActive = isVisible || isForceVisible;
return (
<div className={`VideoPlayerControls ${isForceMobileVersion ? 'mobile' : ''}`} onClick={stopEvent}>
<div
className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isActive && 'active')}
onClick={stopEvent}
>
{renderSeekLine(currentTime, duration, bufferedProgress, seekerRef)}
<Button
ariaLabel={lang('AccActionPlay')}

View File

@ -11,6 +11,7 @@ import {
} from '../../common/helpers/mediaDimensions';
import windowSize from '../../../util/windowSize';
import stopEvent from '../../../util/stopEvent';
import { IS_TOUCH_ENV } from '../../../util/environment';
const ANIMATION_DURATION = 200;
@ -287,8 +288,8 @@ function isMessageImageFullyVisible(container: HTMLElement, imageEl: HTMLElement
function getTopOffset(hasFooter: boolean) {
const mql = window.matchMedia(MEDIA_VIEWER_MEDIA_QUERY);
let topOffsetRem = 4.125;
if (hasFooter) {
topOffsetRem += mql.matches ? 0.875 : 3.375;
if (hasFooter && !IS_TOUCH_ENV) {
topOffsetRem += mql.matches ? 0.875 : 2.125;
}
return topOffsetRem * REM;

19
src/hooks/useTimeout.ts Normal file
View File

@ -0,0 +1,19 @@
import { useEffect, useLayoutEffect, useRef } from '../lib/teact/teact';
function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (typeof delay !== 'number') {
return undefined;
}
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}
export default useTimeout;

View File

@ -57,7 +57,7 @@ type TSwipeAxis =
| 'y'
| undefined;
const IOS_SCREEN_EDGE_THRESHOLD = 20;
export const IOS_SCREEN_EDGE_THRESHOLD = 20;
const MOVED_THRESHOLD = 15;
const SWIPE_THRESHOLD = 50;
@ -96,13 +96,6 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
if (e.type === 'mousedown') {
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onRelease);
if (options.onDoubleClick && Date.now() - lastClickTime < 300) {
options.onDoubleClick(e, {
centerX: e.pageX!,
centerY: e.pageY!,
});
}
lastClickTime = Date.now();
} else if (e.type === 'touchstart') {
// We need to always listen on `touchstart` target:
// https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
@ -150,8 +143,6 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
(captureEvent.target as HTMLElement).removeEventListener('touchend', onRelease);
(captureEvent.target as HTMLElement).removeEventListener('touchmove', onMove);
captureEvent = undefined;
if (IS_IOS && options.selectorToPreventScroll) {
Array.from(document.querySelectorAll<HTMLElement>(options.selectorToPreventScroll)).forEach((scrollable) => {
scrollable.style.overflow = '';
@ -162,8 +153,16 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
if (options.onRelease) {
options.onRelease(e);
}
} else if (options.onClick && (!('button' in e) || e.button === 0)) {
options.onClick(e);
} else if (e.type === 'mouseup') {
if (options.onDoubleClick && Date.now() - lastClickTime < 300) {
options.onDoubleClick(e, {
centerX: captureEvent!.pageX!,
centerY: captureEvent!.pageY!,
});
} else if (options.onClick && (!('button' in e) || e.button === 0)) {
options.onClick(e);
}
lastClickTime = Date.now();
}
}
@ -172,6 +171,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) {
initialDistance = 0;
initialSwipeAxis = undefined;
initialTouchCenter = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
captureEvent = undefined;
}
function onMove(e: MouseEvent | RealTouchEvent) {

View File

@ -30,7 +30,7 @@ module.exports = (env = {}, argv = {}) => {
port: 1234,
host: '0.0.0.0',
disableHostCheck: true,
stats: 'minimal',
stats: 'minimal'
},
output: {
filename: '[name].[contenthash].js',