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

View File

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

View File

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

View File

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

View File

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