Media Viewer: Various fixes and improvements (#1710)

This commit is contained in:
Alexander Zinchuk 2022-02-20 13:39:24 +02:00
parent 831d1589b4
commit 611ec32ee8
6 changed files with 85 additions and 75 deletions

View File

@ -1,4 +1,4 @@
import React, { FC, memo } from '../../lib/teact/teact';
import React, { FC, memo, useCallback } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import {
@ -50,6 +50,7 @@ type OwnProps = {
animationLevel: 0 | 1 | 2;
onClose: () => void;
onFooterClick: () => void;
setIsFooterHidden?: (isHidden: boolean) => void;
isFooterHidden?: boolean;
};
@ -81,6 +82,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
onFooterClick,
isFooterHidden,
isProtected,
setIsFooterHidden,
} = props;
/* Content */
const photo = message ? getMessagePhoto(message) : undefined;
@ -139,6 +141,10 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
isGhostAnimation && ANIMATION_DURATION,
);
const toggleControls = useCallback((isVisible) => {
setIsFooterHidden?.(!isVisible);
}, [setIsFooterHidden]);
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message));
@ -189,7 +195,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
)}
{isVideo && ((!isActive && IS_TOUCH_ENV) ? renderVideoPreview(
bestImageData,
message && calculateMediaViewerDimensions(dimensions!, hasFooter, false),
message && calculateMediaViewerDimensions(dimensions!, hasFooter, true),
!IS_SINGLE_COLUMN_LAYOUT && !isProtected,
) : (
<VideoPlayer
@ -197,10 +203,12 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
url={localBlobUrl || fullMediaBlobUrl}
isGif={isGif}
posterData={bestImageData}
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, false)}
posterSize={message && calculateMediaViewerDimensions(dimensions!, hasFooter, true)}
loadProgress={loadProgress}
fileSize={videoSize!}
isMediaViewerOpen={isOpen && isActive}
areControlsVisible={!isFooterHidden}
toggleControls={toggleControls}
noPlay={!isActive}
onClose={onClose}
/>
@ -209,7 +217,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
<MediaViewerFooter
text={textParts}
onClick={onFooterClick}
isHidden={isFooterHidden}
isHidden={isFooterHidden && IS_TOUCH_ENV}
isForVideo={isVideo && !isGif}
/>
)}

View File

@ -49,6 +49,7 @@ const MAX_ZOOM = 4;
const MIN_ZOOM = 0.6;
const DOUBLE_TAP_ZOOM = 3;
const CLICK_X_THRESHOLD = 40;
const CLICK_Y_THRESHOLD = 80;
let cancelAnimation: Function | undefined;
type Transform = {
@ -105,11 +106,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
const debounceActive = useDebounce(DEBOUNCE_ACTIVE, true);
const handleToggleFooterVisibility = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!IS_TOUCH_ENV || !hasFooter || (!isPhoto && !isGif)) return;
if (e.pageX < CLICK_X_THRESHOLD) return;
if (e.pageX > window.innerWidth - CLICK_X_THRESHOLD) return;
if (!IS_TOUCH_ENV) return;
const isFooter = window.innerHeight - e.pageY < CLICK_Y_THRESHOLD;
if (!isFooter && e.pageX < CLICK_X_THRESHOLD) return;
if (!isFooter && e.pageX > window.innerWidth - CLICK_X_THRESHOLD) return;
setIsFooterHidden(!isFooterHidden);
}, [hasFooter, isFooterHidden, isGif, isPhoto]);
}, [isFooterHidden]);
useTimeout(() => setIsFooterHidden(false), ANIMATION_DURATION - 150);
@ -140,6 +142,9 @@ const MediaViewerSlides: FC<OwnProps> = ({
const changeSlide = (e: MouseEvent) => {
if (transformRef.current.scale !== 1) return false;
let direction = 0;
if (window.innerHeight - e.pageY < CLICK_Y_THRESHOLD) {
return false;
}
if (e.pageX < CLICK_X_THRESHOLD) {
direction = -1;
} else if (e.pageX > window.innerWidth - CLICK_X_THRESHOLD) {
@ -508,8 +513,10 @@ const MediaViewerSlides: FC<OwnProps> = ({
<div className="MediaViewerSlides" ref={containerRef}>
{previousMessageId && scale === 1 && (
<div className="MediaViewerSlide" style={getAnimationStyle(-window.innerWidth + offsetX - SLIDES_GAP)}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<MediaViewerContent {...rest} messageId={previousMessageId} isFooterHidden={isFooterHidden} />
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
messageId={previousMessageId} />
</div>
)}
{activeMessageId && (
@ -524,14 +531,17 @@ const MediaViewerSlides: FC<OwnProps> = ({
{...rest}
messageId={activeMessageId}
isActive={isActive && isActiveRef.current}
setIsFooterHidden={setIsFooterHidden}
isFooterHidden={isFooterHidden || isZoomed || scale !== 1}
/>
</div>
)}
{nextMessageId && scale === 1 && (
<div className="MediaViewerSlide" style={getAnimationStyle(window.innerWidth + offsetX + SLIDES_GAP)}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<MediaViewerContent {...rest} messageId={nextMessageId} isFooterHidden={isFooterHidden} />
<MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
messageId={nextMessageId}/>
</div>
)}
</div>

View File

@ -49,6 +49,11 @@
@media (max-height: 640px) {
max-height: calc(100vh - 10rem);
}
// Disable fullscreen on double tap on mobile devices
.is-touch-env & {
pointer-events: none;
}
}
.play-button {

View File

@ -1,22 +1,19 @@
import React, {
FC, memo, useCallback, useEffect, useRef, useState,
} from '../../lib/teact/teact';
import { ApiDimensions } from '../../api/types';
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
import useShowTransition from '../../hooks/useShowTransition';
import useBuffering from '../../hooks/useBuffering';
import useFullscreenStatus from '../../hooks/useFullscreen';
import useShowTransition from '../../hooks/useShowTransition';
import useVideoCleanup from '../../hooks/useVideoCleanup';
import safePlay from '../../util/safePlay';
import React, { FC, memo, useCallback, useEffect, useRef, useState } from '../../lib/teact/teact';
import VideoPlayerControls from './VideoPlayerControls';
import ProgressSpinner from '../ui/ProgressSpinner';
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
import safePlay from '../../util/safePlay';
import Button from '../ui/Button';
import ProgressSpinner from '../ui/ProgressSpinner';
import './VideoPlayer.scss';
import VideoPlayerControls from './VideoPlayerControls';
type OwnProps = {
url?: string;
isGif?: boolean;
@ -26,6 +23,8 @@ type OwnProps = {
fileSize: number;
isMediaViewerOpen?: boolean;
noPlay?: boolean;
areControlsVisible: boolean;
toggleControls: (isVisible: boolean) => void;
onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
};
@ -41,12 +40,13 @@ const VideoPlayer: FC<OwnProps> = ({
isMediaViewerOpen,
noPlay,
onClose,
toggleControls,
areControlsVisible,
}) => {
// eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlayed, setIsPlayed] = useState(!IS_TOUCH_ENV || !IS_IOS);
const [currentTime, setCurrentTime] = useState(0);
const [isControlsVisible, setIsControlsVisible] = useState(true);
const [isFullscreen, setFullscreen, exitFullscreen] = useFullscreenStatus(videoRef, setIsPlayed);
@ -88,21 +88,20 @@ const VideoPlayer: FC<OwnProps> = ({
} else {
safePlay(videoRef.current!);
setIsPlayed(true);
if (IS_SINGLE_COLUMN_LAYOUT) {
setIsControlsVisible(false);
}
}
}, [isPlayed]);
useVideoCleanup(videoRef, []);
const handleMouseOver = useCallback(() => {
setIsControlsVisible(true);
}, []);
const handleMouseMove = useCallback(() => {
toggleControls(true);
}, [toggleControls]);
const handleMouseOut = useCallback(() => {
setIsControlsVisible(false);
}, []);
const handleMouseOut = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (e.target === videoRef.current) {
toggleControls(false);
}
}, [toggleControls]);
const handleTimeUpdate = useCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
setCurrentTime(e.currentTarget.currentTime);
@ -111,8 +110,8 @@ const VideoPlayer: FC<OwnProps> = ({
const handleEnded = useCallback(() => {
setCurrentTime(0);
setIsPlayed(false);
setIsControlsVisible(true);
}, []);
toggleControls(true);
}, [toggleControls]);
const handleFullscreenChange = useCallback(() => {
if (isFullscreen && exitFullscreen) {
@ -126,11 +125,6 @@ const VideoPlayer: FC<OwnProps> = ({
videoRef.current!.currentTime = position;
}, []);
const toggleControls = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
setIsControlsVisible(!isControlsVisible);
}, [isControlsVisible]);
useEffect(() => {
const togglePayingStateBySpace = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -152,8 +146,7 @@ const VideoPlayer: FC<OwnProps> = ({
return (
<div
className="VideoPlayer"
onClick={!isGif && IS_SINGLE_COLUMN_LAYOUT ? toggleControls : undefined}
onMouseOver={!isGif && !IS_TOUCH_ENV ? handleMouseOver : undefined}
onMouseMove={!isGif && !IS_TOUCH_ENV ? handleMouseMove : undefined}
onMouseOut={!isGif && !IS_TOUCH_ENV ? handleMouseOut : undefined}
>
<div
@ -172,7 +165,7 @@ const VideoPlayer: FC<OwnProps> = ({
onPlay={IS_IOS ? () => setIsPlayed(true) : undefined}
onEnded={handleEnded}
onClick={!IS_SINGLE_COLUMN_LAYOUT ? togglePlayState : undefined}
onDoubleClick={handleFullscreenChange}
onDoubleClick={!IS_TOUCH_ENV ? handleFullscreenChange : undefined}
// eslint-disable-next-line react/jsx-props-no-spreading
{...bufferingHandlers}
onTimeUpdate={handleTimeUpdate}
@ -205,7 +198,8 @@ const VideoPlayer: FC<OwnProps> = ({
isFullscreen={isFullscreen}
fileSize={fileSize}
duration={videoRef.current ? videoRef.current.duration || 0 : 0}
isForceVisible={isControlsVisible}
isVisible={areControlsVisible}
setVisibility={toggleControls}
isForceMobileVersion={posterSize && posterSize.width < MOBILE_VERSION_CONTROL_WIDTH}
onSeek={handleSeek}
onChangeFullscreen={handleFullscreenChange}

View File

@ -5,10 +5,10 @@
left: 0;
bottom: 0;
width: 100%;
padding-top: 0.625rem;
padding: 1rem 0.5rem 0.5rem;
font-size: 0.875rem;
background: linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 100%);
transition: opacity 0.15s;
transition: opacity 0.3s;
opacity: 0;
pointer-events: none;

View File

@ -1,5 +1,5 @@
import React, {
FC, useState, useEffect, useRef, useCallback,
FC, useEffect, useRef, useCallback,
} from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
@ -18,13 +18,14 @@ type IProps = {
currentTime: number;
duration: number;
fileSize: number;
isForceVisible: boolean;
isForceMobileVersion?: boolean;
isPlayed: boolean;
isFullscreenSupported: boolean;
isFullscreen: boolean;
onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onPlayPause: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
isVisible: boolean;
setVisibility: (isVisible: boolean) => void;
onSeek: (position: number) => void;
};
@ -32,68 +33,61 @@ const stopEvent = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
};
const HIDE_CONTROLS_TIMEOUT_MS = 800;
const HIDE_CONTROLS_TIMEOUT_MS = 1500;
const VideoPlayerControls: FC<IProps> = ({
bufferedProgress,
currentTime,
duration,
fileSize,
isForceVisible,
isForceMobileVersion,
isPlayed,
isFullscreenSupported,
isFullscreen,
onChangeFullscreen,
onPlayPause,
isVisible,
setVisibility,
onSeek,
}) => {
const [isVisible, setVisibility] = useState(true);
// eslint-disable-next-line no-null/no-null
const seekerRef = useRef<HTMLDivElement>(null);
const isSeeking = useRef<boolean>(false);
useEffect(() => {
if (isForceVisible) {
setVisibility(isForceVisible);
}
}, [isForceVisible]);
useEffect(() => {
let timeout: number | undefined;
if (!isForceVisible) {
if (IS_SINGLE_COLUMN_LAYOUT) {
setVisibility(false);
} else {
timeout = window.setTimeout(() => {
setVisibility(false);
}, HIDE_CONTROLS_TIMEOUT_MS);
}
if (!isVisible || !isPlayed) {
if (timeout) window.clearTimeout(timeout);
return;
}
timeout = window.setTimeout(() => {
setVisibility(false);
}, HIDE_CONTROLS_TIMEOUT_MS);
return () => {
if (timeout) {
window.clearTimeout(timeout);
}
if (timeout) window.clearTimeout(timeout);
};
}, [isForceVisible]);
}, [isPlayed, isVisible, setVisibility]);
useEffect(() => {
if (isVisible || isForceVisible) {
if (isVisible) {
document.body.classList.add('video-controls-visible');
} else {
document.body.classList.remove('video-controls-visible');
}
return () => {
document.body.classList.remove('video-controls-visible');
};
}, [isForceVisible, isVisible]);
}, [isVisible]);
const lang = useLang();
const handleSeek = useCallback((e: MouseEvent | TouchEvent) => {
if (isSeeking.current && seekerRef.current) {
const { width, left } = seekerRef.current.getBoundingClientRect();
const {
width,
left,
} = seekerRef.current.getBoundingClientRect();
const clientX = e instanceof MouseEvent ? e.clientX : e.targetTouches[0].clientX;
onSeek(Math.max(Math.min(duration * ((clientX - left) / width), duration), 0));
}
@ -118,11 +112,10 @@ const VideoPlayerControls: FC<IProps> = ({
});
}, [isVisible, handleStartSeek, handleSeek, handleStopSeek]);
const isActive = isVisible || isForceVisible;
return (
<div
className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isActive && 'active')}
className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isVisible && 'active')}
onClick={stopEvent}
>
{renderSeekLine(currentTime, duration, bufferedProgress, seekerRef)}