Media Viewer: Introduce Picture-in-Picture (#2015)

This commit is contained in:
Alexander Zinchuk 2022-10-10 14:37:51 +02:00
parent 103e5ed8e9
commit de772bfd59
16 changed files with 472 additions and 246 deletions

Binary file not shown.

Binary file not shown.

View File

@ -37,6 +37,7 @@ import { renderMessageText } from '../common/helpers/renderMessageText';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useForceUpdate from '../../hooks/useForceUpdate'; import useForceUpdate from '../../hooks/useForceUpdate';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import { exitPictureInPictureIfNeeded } from '../../hooks/usePictureInPicture';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import { useMediaProps } from './hooks/useMediaProps'; import { useMediaProps } from './hooks/useMediaProps';
@ -62,6 +63,7 @@ type StateProps = {
message?: ApiMessage; message?: ApiMessage;
chatMessages?: Record<number, ApiMessage>; chatMessages?: Record<number, ApiMessage>;
collectionIds?: number[]; collectionIds?: number[];
isHidden?: boolean;
animationLevel: AnimationLevel; animationLevel: AnimationLevel;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
}; };
@ -80,6 +82,7 @@ const MediaViewer: FC<StateProps> = ({
chatMessages, chatMessages,
collectionIds, collectionIds,
animationLevel, animationLevel,
isHidden,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
}) => { }) => {
const { const {
@ -120,6 +123,7 @@ const MediaViewer: FC<StateProps> = ({
}); });
const canReport = !!avatarPhoto && !isChatWithSelf; const canReport = !!avatarPhoto && !isChatWithSelf;
const isVisible = !isHidden && isOpen;
/* Navigation */ /* Navigation */
const singleMediaId = webPagePhoto || webPageVideo ? mediaId : undefined; const singleMediaId = webPagePhoto || webPageVideo ? mediaId : undefined;
@ -140,6 +144,12 @@ const MediaViewer: FC<StateProps> = ({
animationKey.current = selectedMediaIndex; animationKey.current = selectedMediaIndex;
} }
useEffect(() => {
if (isVisible) {
exitPictureInPictureIfNeeded();
}
}, [isVisible]);
useEffect(() => { useEffect(() => {
if (!IS_SINGLE_COLUMN_LAYOUT) return; if (!IS_SINGLE_COLUMN_LAYOUT) return;
document.body.classList.toggle('is-media-viewer-open', isOpen); document.body.classList.toggle('is-media-viewer-open', isOpen);
@ -164,14 +174,17 @@ const MediaViewer: FC<StateProps> = ({
}, [forceUpdate]); }, [forceUpdate]);
const prevMessage = usePrevious<ApiMessage | undefined>(message); const prevMessage = usePrevious<ApiMessage | undefined>(message);
const prevIsHidden = usePrevious<boolean | undefined>(isHidden);
const prevOrigin = usePrevious(origin); const prevOrigin = usePrevious(origin);
const prevMediaId = usePrevious(mediaId);
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner); const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
const prevBestImageData = usePrevious(bestImageData); const prevBestImageData = usePrevious(bestImageData);
const textParts = message ? renderMessageText(message) : undefined; const textParts = message ? renderMessageText(message) : undefined;
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;
useEffect(() => { useEffect(() => {
if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) { if (isGhostAnimation && isOpen && (!prevMessage || shouldAnimateOpening) && !prevAvatarOwner) {
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message); animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message);
} }
@ -181,7 +194,7 @@ const MediaViewer: FC<StateProps> = ({
animateClosing(prevOrigin!, prevBestImageData!, prevMessage || undefined); animateClosing(prevOrigin!, prevBestImageData!, prevMessage || undefined);
} }
}, [ }, [
isGhostAnimation, isOpen, origin, prevOrigin, message, prevMessage, prevAvatarOwner, isGhostAnimation, isOpen, shouldAnimateOpening, origin, prevOrigin, message, prevMessage, prevAvatarOwner,
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter, bestImageData, prevBestImageData, dimensions, isVideo, hasFooter,
]); ]);
@ -269,7 +282,12 @@ const MediaViewer: FC<StateProps> = ({
} }
return ( return (
<ShowTransition id="MediaViewer" isOpen={isOpen} noCloseTransition={shouldSkipHistoryAnimations}> <ShowTransition
id="MediaViewer"
isOpen={isOpen}
isHidden={isHidden}
noCloseTransition={shouldSkipHistoryAnimations}
>
<div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}> <div className="media-viewer-head" dir={lang.isRtl ? 'rtl' : undefined}>
{IS_SINGLE_COLUMN_LAYOUT && ( {IS_SINGLE_COLUMN_LAYOUT && (
<Button <Button
@ -323,6 +341,7 @@ const MediaViewer: FC<StateProps> = ({
animationLevel={animationLevel} animationLevel={animationLevel}
onClose={handleClose} onClose={handleClose}
selectMedia={selectMedia} selectMedia={selectMedia}
isHidden={isHidden}
onFooterClick={handleFooterClick} onFooterClick={handleFooterClick}
/> />
</ShowTransition> </ShowTransition>
@ -337,6 +356,7 @@ export default memo(withGlobal(
mediaId, mediaId,
avatarOwnerId, avatarOwnerId,
origin, origin,
isHidden,
} = global.mediaViewer; } = global.mediaViewer;
const { const {
animationLevel, animationLevel,
@ -364,6 +384,7 @@ export default memo(withGlobal(
origin, origin,
message, message,
animationLevel, animationLevel,
isHidden,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
}; };
} }
@ -380,6 +401,7 @@ export default memo(withGlobal(
animationLevel, animationLevel,
origin, origin,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
isHidden,
}; };
} }
@ -426,6 +448,7 @@ export default memo(withGlobal(
chatMessages, chatMessages,
collectionIds, collectionIds,
animationLevel, animationLevel,
isHidden,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
}; };
}, },

View File

@ -49,6 +49,7 @@ type StateProps = {
isProtected?: boolean; isProtected?: boolean;
volume: number; volume: number;
isMuted: boolean; isMuted: boolean;
isHidden?: boolean;
playbackRate: number; playbackRate: number;
}; };
@ -68,6 +69,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
volume, volume,
playbackRate, playbackRate,
isMuted, isMuted,
isHidden,
onClose, onClose,
onFooterClick, onFooterClick,
setControlsVisible, setControlsVisible,
@ -170,6 +172,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
noPlay={!isActive} noPlay={!isActive}
onClose={onClose} onClose={onClose}
isMuted={isMuted} isMuted={isMuted}
isHidden={isHidden}
isProtected={isProtected} isProtected={isProtected}
volume={volume} volume={volume}
playbackRate={playbackRate} playbackRate={playbackRate}
@ -202,6 +205,7 @@ export default memo(withGlobal<OwnProps>(
volume, volume,
isMuted, isMuted,
playbackRate, playbackRate,
isHidden,
} = global.mediaViewer; } = global.mediaViewer;
if (origin === MediaViewerOrigin.SearchResult) { if (origin === MediaViewerOrigin.SearchResult) {
@ -223,6 +227,7 @@ export default memo(withGlobal<OwnProps>(
isProtected: selectIsMessageProtected(global, message), isProtected: selectIsMessageProtected(global, message),
volume, volume,
isMuted, isMuted,
isHidden,
playbackRate, playbackRate,
}; };
} }
@ -237,6 +242,7 @@ export default memo(withGlobal<OwnProps>(
origin, origin,
volume, volume,
isMuted, isMuted,
isHidden,
playbackRate, playbackRate,
}; };
} }
@ -266,6 +272,7 @@ export default memo(withGlobal<OwnProps>(
isProtected: selectIsMessageProtected(global, message), isProtected: selectIsMessageProtected(global, message),
volume, volume,
isMuted, isMuted,
isHidden,
playbackRate, playbackRate,
}; };
}, },

View File

@ -41,6 +41,7 @@ type OwnProps = {
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
animationLevel: AnimationLevel; animationLevel: AnimationLevel;
onClose: () => void; onClose: () => void;
isHidden?: boolean;
hasFooter?: boolean; hasFooter?: boolean;
onFooterClick: () => void; onFooterClick: () => void;
zoomLevelChange: number; zoomLevelChange: number;
@ -83,6 +84,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
hasFooter, hasFooter,
zoomLevelChange, zoomLevelChange,
animationLevel, animationLevel,
isHidden,
...rest ...rest
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -139,7 +141,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
useTimeout(() => setControlsVisible(true), ANIMATION_DURATION + 100); useTimeout(() => setControlsVisible(true), ANIMATION_DURATION + 100);
useEffect(() => { useEffect(() => {
if (!containerRef.current || activeMediaId === undefined) { setActiveMediaId(mediaId);
}, [mediaId]);
useEffect(() => {
if (!containerRef.current || activeMediaId === undefined || isHidden) {
return undefined; return undefined;
} }
let lastTransform = lastTransformRef.current; let lastTransform = lastTransformRef.current;
@ -624,10 +630,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
clearSwipeDirectionDebounced, clearSwipeDirectionDebounced,
animationLevel, animationLevel,
setIsMouseDown, setIsMouseDown,
isHidden,
]); ]);
useEffect(() => { useEffect(() => {
if (!containerRef.current || !hasZoomChanged) return; if (!containerRef.current || !hasZoomChanged || isHidden) return;
const { scale } = transformRef.current; const { scale } = transformRef.current;
const dir = zoomLevelChange > 0 ? -1 : +1; const dir = zoomLevelChange > 0 ? -1 : +1;
const minZoom = MIN_ZOOM * 0.6; const minZoom = MIN_ZOOM * 0.6;
@ -655,7 +662,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
containerRef.current.dispatchEvent(wheelEvent); containerRef.current.dispatchEvent(wheelEvent);
}, },
}); });
}, [zoomLevelChange, hasZoomChanged]); }, [zoomLevelChange, hasZoomChanged, isHidden]);
if (activeMediaId === undefined) return undefined; if (activeMediaId === undefined) return undefined;

View File

@ -8,6 +8,7 @@ import type { ApiDimensions } from '../../api/types';
import useBuffering from '../../hooks/useBuffering'; import useBuffering from '../../hooks/useBuffering';
import useFullscreenStatus from '../../hooks/useFullscreen'; import useFullscreenStatus from '../../hooks/useFullscreen';
import usePictureInPicture from '../../hooks/usePictureInPicture';
import useShowTransition from '../../hooks/useShowTransition'; import useShowTransition from '../../hooks/useShowTransition';
import useVideoCleanup from '../../hooks/useVideoCleanup'; import useVideoCleanup from '../../hooks/useVideoCleanup';
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment'; import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
@ -31,6 +32,7 @@ type OwnProps = {
noPlay?: boolean; noPlay?: boolean;
volume: number; volume: number;
isMuted: boolean; isMuted: boolean;
isHidden?: boolean;
playbackRate: number; playbackRate: number;
isProtected?: boolean; isProtected?: boolean;
areControlsVisible: boolean; areControlsVisible: boolean;
@ -39,6 +41,7 @@ type OwnProps = {
}; };
const MOBILE_VERSION_CONTROL_WIDTH = 400; const MOBILE_VERSION_CONTROL_WIDTH = 400;
const MAX_LOOP_DURATION = 30; // Seconds
const VideoPlayer: FC<OwnProps> = ({ const VideoPlayer: FC<OwnProps> = ({
url, url,
@ -61,12 +64,21 @@ const VideoPlayer: FC<OwnProps> = ({
setMediaViewerVolume, setMediaViewerVolume,
setMediaViewerMuted, setMediaViewerMuted,
setMediaViewerPlaybackRate, setMediaViewerPlaybackRate,
setMediaViewerHidden,
} = getActions(); } = getActions();
// 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 [isPlaying, setIsPlaying] = useState(!IS_TOUCH_ENV || !IS_IOS);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [isFullscreen, setFullscreen, exitFullscreen] = useFullscreenStatus(videoRef, setIsPlayed); const [isFullscreen, setFullscreen, exitFullscreen] = useFullscreenStatus(videoRef, setIsPlaying);
const handleEnterFullscreen = useCallback(() => setMediaViewerHidden(true), [setMediaViewerHidden]);
const handleLeaveFullscreen = useCallback(() => setMediaViewerHidden(false), [setMediaViewerHidden]);
const [
isPictureInPictureSupported,
enterPictureInPicture,
] = usePictureInPicture(videoRef, handleEnterFullscreen, handleLeaveFullscreen);
const handleVideoMove = useCallback(() => { const handleVideoMove = useCallback(() => {
toggleControls(true); toggleControls(true);
@ -90,7 +102,7 @@ const VideoPlayer: FC<OwnProps> = ({
const { const {
shouldRender: shouldRenderPlayButton, shouldRender: shouldRenderPlayButton,
transitionClassNames: playButtonClassNames, transitionClassNames: playButtonClassNames,
} = useShowTransition(IS_IOS && !isPlayed && !shouldRenderSpinner, undefined, undefined, 'slow'); } = useShowTransition(IS_IOS && !isPlaying && !shouldRenderSpinner, undefined, undefined, 'slow');
useEffect(() => { useEffect(() => {
if (noPlay || !isMediaViewerOpen) { if (noPlay || !isMediaViewerOpen) {
@ -101,12 +113,12 @@ const VideoPlayer: FC<OwnProps> = ({
// so we need to use `autoPlay` instead to allow pre-buffering. // so we need to use `autoPlay` instead to allow pre-buffering.
safePlay(videoRef.current!); safePlay(videoRef.current!);
} }
}, [noPlay, isMediaViewerOpen, url]); }, [noPlay, isMediaViewerOpen, url, setMediaViewerMuted]);
useEffect(() => { useEffect(() => {
if (videoRef.current!.currentTime === videoRef.current!.duration) { if (videoRef.current!.currentTime === videoRef.current!.duration) {
setCurrentTime(0); setCurrentTime(0);
setIsPlayed(false); setIsPlaying(false);
} else { } else {
setCurrentTime(videoRef.current!.currentTime); setCurrentTime(videoRef.current!.currentTime);
} }
@ -122,14 +134,14 @@ const VideoPlayer: FC<OwnProps> = ({
const togglePlayState = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent) => { const togglePlayState = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
if (isPlayed) { if (isPlaying) {
videoRef.current!.pause(); videoRef.current!.pause();
setIsPlayed(false); setIsPlaying(false);
} else { } else {
safePlay(videoRef.current!); safePlay(videoRef.current!);
setIsPlayed(true); setIsPlaying(true);
} }
}, [isPlayed]); }, [isPlaying]);
useVideoCleanup(videoRef, []); useVideoCleanup(videoRef, []);
@ -139,7 +151,7 @@ const VideoPlayer: FC<OwnProps> = ({
const handleEnded = useCallback(() => { const handleEnded = useCallback(() => {
setCurrentTime(0); setCurrentTime(0);
setIsPlayed(false); setIsPlaying(false);
toggleControls(true); toggleControls(true);
}, [toggleControls]); }, [toggleControls]);
@ -160,6 +172,8 @@ const VideoPlayer: FC<OwnProps> = ({
}, [setMediaViewerVolume]); }, [setMediaViewerVolume]);
const handleVolumeMuted = useCallback(() => { const handleVolumeMuted = useCallback(() => {
// Browser requires explicit user interaction to keep video playing after unmuting
videoRef.current!.muted = !videoRef.current!.muted;
setMediaViewerMuted({ isMuted: !isMuted }); setMediaViewerMuted({ isMuted: !isMuted });
}, [isMuted, setMediaViewerMuted]); }, [isMuted, setMediaViewerMuted]);
@ -185,6 +199,7 @@ const VideoPlayer: FC<OwnProps> = ({
const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`; const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`;
const videoStyle = `background-image: url(${posterData})`; const videoStyle = `background-image: url(${posterData})`;
const duration = videoRef.current?.duration || 0;
return ( return (
<div <div
@ -209,17 +224,21 @@ const VideoPlayer: FC<OwnProps> = ({
autoPlay={IS_TOUCH_ENV} autoPlay={IS_TOUCH_ENV}
controlsList={isProtected ? 'nodownload' : undefined} controlsList={isProtected ? 'nodownload' : undefined}
playsInline playsInline
loop={isGif} loop={isGif || duration <= MAX_LOOP_DURATION}
// This is to force auto playing on mobiles // This is to force autoplaying on mobiles
muted={isGif || isMuted} muted={isGif || isMuted}
id="media-viewer-video" id="media-viewer-video"
style={videoStyle} style={videoStyle}
onPlay={IS_IOS ? () => setIsPlayed(true) : undefined} onPlay={() => setIsPlaying(true)}
onEnded={handleEnded} onEnded={handleEnded}
onClick={!IS_SINGLE_COLUMN_LAYOUT ? togglePlayState : undefined} onClick={!IS_SINGLE_COLUMN_LAYOUT ? togglePlayState : undefined}
onDoubleClick={!IS_TOUCH_ENV ? handleFullscreenChange : undefined} 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}
onPause={(e) => {
setIsPlaying(false);
bufferingHandlers.onPause(e);
}}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
> >
{url && <source src={url} />} {url && <source src={url} />}
@ -243,20 +262,22 @@ const VideoPlayer: FC<OwnProps> = ({
)} )}
{!isGif && !shouldRenderSpinner && ( {!isGif && !shouldRenderSpinner && (
<VideoPlayerControls <VideoPlayerControls
isPlayed={isPlayed} isPlaying={isPlaying}
bufferedRanges={bufferedRanges} bufferedRanges={bufferedRanges}
bufferedProgress={bufferedProgress} bufferedProgress={bufferedProgress}
isBuffered={isBuffered} isBuffered={isBuffered}
currentTime={currentTime} currentTime={currentTime}
isFullscreenSupported={Boolean(setFullscreen)} isFullscreenSupported={Boolean(setFullscreen)}
isPictureInPictureSupported={isPictureInPictureSupported}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
fileSize={fileSize} fileSize={fileSize}
duration={videoRef.current ? videoRef.current.duration || 0 : 0} duration={duration}
isVisible={areControlsVisible} isVisible={areControlsVisible}
setVisibility={toggleControls} 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}
onPictureInPictureChange={enterPictureInPicture}
onPlayPause={togglePlayState} onPlayPause={togglePlayState}
volume={volume} volume={volume}
playbackRate={playbackRate} playbackRate={playbackRate}

View File

@ -26,8 +26,9 @@ type OwnProps = {
duration: number; duration: number;
fileSize: number; fileSize: number;
isForceMobileVersion?: boolean; isForceMobileVersion?: boolean;
isPlayed: boolean; isPlaying: boolean;
isFullscreenSupported: boolean; isFullscreenSupported: boolean;
isPictureInPictureSupported: boolean;
isFullscreen: boolean; isFullscreen: boolean;
isVisible: boolean; isVisible: boolean;
isBuffered: boolean; isBuffered: boolean;
@ -35,6 +36,7 @@ type OwnProps = {
isMuted: boolean; isMuted: boolean;
playbackRate: number; playbackRate: number;
onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onPictureInPictureChange?: () => void ;
onVolumeClick: () => void; onVolumeClick: () => void;
onVolumeChange: (volume: number) => void; onVolumeChange: (volume: number) => void;
onPlaybackRateChange: (playbackRate: number) => void; onPlaybackRateChange: (playbackRate: number) => void;
@ -63,7 +65,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
duration, duration,
fileSize, fileSize,
isForceMobileVersion, isForceMobileVersion,
isPlayed, isPlaying,
isFullscreenSupported, isFullscreenSupported,
isFullscreen, isFullscreen,
isVisible, isVisible,
@ -75,6 +77,8 @@ const VideoPlayerControls: FC<OwnProps> = ({
onVolumeClick, onVolumeClick,
onVolumeChange, onVolumeChange,
onPlaybackRateChange, onPlaybackRateChange,
isPictureInPictureSupported,
onPictureInPictureChange,
onPlayPause, onPlayPause,
setVisibility, setVisibility,
onSeek, onSeek,
@ -88,7 +92,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
useEffect(() => { useEffect(() => {
if (!IS_TOUCH_ENV) return undefined; if (!IS_TOUCH_ENV) return undefined;
let timeout: number | undefined; let timeout: number | undefined;
if (!isVisible || !isPlayed || isSeeking || isPlaybackMenuOpen) { if (!isVisible || !isPlaying || isSeeking || isPlaybackMenuOpen) {
if (timeout) window.clearTimeout(timeout); if (timeout) window.clearTimeout(timeout);
return undefined; return undefined;
} }
@ -98,7 +102,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
return () => { return () => {
if (timeout) window.clearTimeout(timeout); if (timeout) window.clearTimeout(timeout);
}; };
}, [isPlayed, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen]); }, [isPlaying, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen]);
useEffect(() => { useEffect(() => {
if (isVisible) { if (isVisible) {
@ -172,7 +176,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
round round
onClick={onPlayPause} onClick={onPlayPause}
> >
<i className={isPlayed ? 'icon-pause' : 'icon-play'} /> <i className={isPlaying ? 'icon-pause' : 'icon-play'} />
</Button> </Button>
<Button <Button
ariaLabel="Volume" ariaLabel="Volume"
@ -204,6 +208,18 @@ const VideoPlayerControls: FC<OwnProps> = ({
> >
{`${playbackRate}x`} {`${playbackRate}x`}
</Button> </Button>
{isPictureInPictureSupported && (
<Button
ariaLabel="Picture in picture"
size="tiny"
color="translucent-white"
className="fullscreen"
round
onClick={onPictureInPictureChange}
>
<i className="icon-pip" />
</Button>
)}
{isFullscreenSupported && ( {isFullscreenSupported && (
<Button <Button
ariaLabel="Fullscreen" ariaLabel="Fullscreen"

View File

@ -8,6 +8,7 @@ import buildClassName from '../../util/buildClassName';
type OwnProps = { type OwnProps = {
isOpen: boolean; isOpen: boolean;
isCustom?: boolean; isCustom?: boolean;
isHidden?: boolean;
id?: string; id?: string;
className?: string; className?: string;
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
@ -17,18 +18,19 @@ type OwnProps = {
const ShowTransition: FC<OwnProps> = ({ const ShowTransition: FC<OwnProps> = ({
isOpen, isOpen,
isHidden,
isCustom, isCustom,
id, id,
className, className,
onClick, onClick,
noCloseTransition,
children, children,
noCloseTransition,
}) => { }) => {
const { const {
shouldRender, shouldRender,
transitionClassNames, transitionClassNames,
} = useShowTransition( } = useShowTransition(
isOpen, undefined, undefined, isCustom ? false : undefined, noCloseTransition, isOpen && !isHidden, undefined, undefined, isCustom ? false : undefined, noCloseTransition,
); );
const prevIsOpen = usePrevious(isOpen); const prevIsOpen = usePrevious(isOpen);
const prevChildren = usePrevious(children); const prevChildren = usePrevious(children);
@ -39,7 +41,7 @@ const ShowTransition: FC<OwnProps> = ({
} }
return ( return (
shouldRender && ( (shouldRender || isHidden) && (
<div id={id} className={buildClassName(className, transitionClassNames)} onClick={onClick}> <div id={id} className={buildClassName(className, transitionClassNames)} onClick={onClick}>
{isOpen ? children : fromChildrenRef.current!} {isOpen ? children : fromChildrenRef.current!}
</div> </div>

View File

@ -15,6 +15,7 @@ addActionHandler('openMediaViewer', (global, actions, payload) => {
avatarOwnerId, avatarOwnerId,
profilePhotoIndex, profilePhotoIndex,
origin, origin,
isHidden: false,
volume: volume ?? global.mediaViewer.volume, volume: volume ?? global.mediaViewer.volume,
playbackRate: playbackRate || global.mediaViewer.playbackRate, playbackRate: playbackRate || global.mediaViewer.playbackRate,
isMuted: isMuted || global.mediaViewer.isMuted, isMuted: isMuted || global.mediaViewer.isMuted,
@ -24,12 +25,15 @@ addActionHandler('openMediaViewer', (global, actions, payload) => {
}); });
addActionHandler('closeMediaViewer', (global) => { addActionHandler('closeMediaViewer', (global) => {
const { volume, isMuted, playbackRate } = global.mediaViewer; const {
volume, isMuted, playbackRate, isHidden,
} = global.mediaViewer;
return { return {
...global, ...global,
mediaViewer: { mediaViewer: {
volume, volume,
isMuted, isMuted,
isHidden,
playbackRate, playbackRate,
}, },
}; };
@ -77,3 +81,15 @@ addActionHandler('setMediaViewerMuted', (global, actions, payload) => {
}, },
}; };
}); });
addActionHandler('setMediaViewerHidden', (global, actions, payload) => {
const isHidden = payload;
return {
...global,
mediaViewer: {
...global.mediaViewer,
isHidden,
},
};
});

View File

@ -425,6 +425,7 @@ export type GlobalState = {
volume: number; volume: number;
playbackRate: number; playbackRate: number;
isMuted: boolean; isMuted: boolean;
isHidden?: boolean;
}; };
audioPlayer: { audioPlayer: {
@ -803,7 +804,7 @@ export interface ActionPayloads {
setMediaViewerMuted: { setMediaViewerMuted: {
isMuted: boolean; isMuted: boolean;
}; };
setMediaViewerHidden: boolean;
openAudioPlayer: { openAudioPlayer: {
chatId: string; chatId: string;
threadId?: number; threadId?: number;

View File

@ -1,8 +1,8 @@
import { useLayoutEffect, useState } from '../lib/teact/teact'; import { useLayoutEffect, useState } from '../lib/teact/teact';
import { PLATFORM_ENV } from '../util/environment'; import { IS_IOS } from '../util/environment';
type RefType = { type RefType = {
current: HTMLElement | null; current: HTMLVideoElement | null;
}; };
type ReturnType = [boolean, () => void, () => void] | [false]; type ReturnType = [boolean, () => void, () => void] | [false];
@ -14,20 +14,10 @@ export default function useFullscreenStatus(elRef: RefType, setIsPlayed: Callbac
const [isFullscreen, setIsFullscreen] = useState(Boolean(prop && document[prop])); const [isFullscreen, setIsFullscreen] = useState(Boolean(prop && document[prop]));
const setFullscreen = () => { const setFullscreen = () => {
if (!elRef.current || !(prop || PLATFORM_ENV === 'iOS')) { if (!elRef.current || !(prop || IS_IOS)) {
return; return;
} }
safeRequestFullscreen(elRef.current);
if (elRef.current.requestFullscreen) {
elRef.current.requestFullscreen();
} else if (elRef.current.webkitRequestFullscreen) {
elRef.current.webkitRequestFullscreen();
} else if (elRef.current.webkitEnterFullscreen) {
elRef.current.webkitEnterFullscreen();
} else if (elRef.current.mozRequestFullScreen) {
elRef.current.mozRequestFullScreen();
}
setIsFullscreen(true); setIsFullscreen(true);
}; };
@ -35,17 +25,7 @@ export default function useFullscreenStatus(elRef: RefType, setIsPlayed: Callbac
if (!elRef.current) { if (!elRef.current) {
return; return;
} }
safeExitFullscreen();
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitCancelFullScreen) {
document.webkitCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
setIsFullscreen(false); setIsFullscreen(false);
}; };
@ -79,7 +59,7 @@ export default function useFullscreenStatus(elRef: RefType, setIsPlayed: Callbac
// eslint-disable-next-line // eslint-disable-next-line
}, []); }, []);
if (!prop && PLATFORM_ENV !== 'iOS') { if (!prop && !IS_IOS) {
return [false]; return [false];
} }
@ -97,3 +77,27 @@ function getBrowserFullscreenElementProp() {
return ''; return '';
} }
export function safeRequestFullscreen(video: HTMLVideoElement) {
if (video.requestFullscreen) {
video.requestFullscreen();
} else if (video.webkitRequestFullscreen) {
video.webkitRequestFullscreen();
} else if (video.webkitEnterFullscreen) {
video.webkitEnterFullscreen();
} else if (video.mozRequestFullScreen) {
video.mozRequestFullScreen();
}
}
export function safeExitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitCancelFullScreen) {
document.webkitCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
}

View File

@ -0,0 +1,107 @@
import { useLayoutEffect, useCallback, useState } from '../lib/teact/teact';
import { DEBUG } from '../config';
import { IS_IOS, IS_PWA } from '../util/environment';
import safePlay, { getIsVideoPlaying } from '../util/safePlay';
type RefType = {
current: HTMLVideoElement | null;
};
type ReturnType = [boolean, () => void] | [false];
type CallbackType = () => void;
export default function usePictureInPicture(
elRef: RefType,
onEnter: CallbackType,
onLeave: CallbackType,
): ReturnType {
const [isSupported, setIsSupported] = useState(false);
useLayoutEffect(() => {
// PIP is not supported in PWA on iOS, despite being detected
if ((IS_IOS && IS_PWA) || !elRef.current) return undefined;
const video = elRef.current;
const setMode = getSetPresentationMode(video);
const isEnabled = (document.pictureInPictureEnabled && !elRef.current?.disablePictureInPicture)
|| setMode !== undefined;
if (!isEnabled) return undefined;
// @ts-ignore
video.autoPictureInPicture = true;
setIsSupported(true);
video.addEventListener('enterpictureinpicture', onEnter);
video.addEventListener('leavepictureinpicture', onLeave);
return () => {
video.removeEventListener('enterpictureinpicture', onEnter);
video.removeEventListener('leavepictureinpicture', onLeave);
};
}, [elRef, onEnter, onLeave]);
const exitPictureInPicture = useCallback(() => {
if (!elRef.current) return;
const video = elRef.current;
const setMode = getSetPresentationMode(video);
if (setMode) {
setMode('inline');
} else {
exitPictureInPictureIfNeeded();
}
}, [elRef]);
const enterPictureInPicture = useCallback(() => {
if (!elRef.current) return;
exitPictureInPicture();
const video = elRef.current;
const isPlaying = getIsVideoPlaying(video);
const setMode = getSetPresentationMode(video);
if (setMode) {
setMode('picture-in-picture');
} else {
requestPictureInPicture(video);
}
// Muted video stops in PiP mode, so we need to play it again
if (isPlaying) {
safePlay(video);
}
}, [elRef, exitPictureInPicture]);
if (!isSupported) {
return [false];
}
return [isSupported, enterPictureInPicture];
}
function getSetPresentationMode(video: HTMLVideoElement) {
// @ts-ignore
if (video.webkitSupportsPresentationMode && typeof video.webkitSetPresentationMode === 'function') {
// @ts-ignore
return video.webkitSetPresentationMode.bind(video);
}
return undefined;
}
function requestPictureInPicture(video: HTMLVideoElement) {
if (video.requestPictureInPicture) {
try {
video.requestPictureInPicture();
} catch (err) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[MV] PictureInPicture Error', err);
}
}
}
}
export function exitPictureInPictureIfNeeded() {
if (document.pictureInPictureElement) {
try {
document.exitPictureInPicture();
} catch (err) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log('[MV] PictureInPicture Error', err);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,9 @@
.icon-volume-3:before { .icon-volume-3:before {
content: "\e991"; content: "\e991";
} }
.icon-pip:before {
content: "\e9ae";
}
.icon-gift:before { .icon-gift:before {
content: "\e9ad"; content: "\e9ad";
} }

View File

@ -21,10 +21,12 @@ export function getPlatform() {
const iosPlatforms = ['iPhone', 'iPad', 'iPod']; const iosPlatforms = ['iPhone', 'iPad', 'iPod'];
let os: 'macOS' | 'iOS' | 'Windows' | 'Android' | 'Linux' | undefined; let os: 'macOS' | 'iOS' | 'Windows' | 'Android' | 'Linux' | undefined;
if (macosPlatforms.indexOf(platform) !== -1) { if (iosPlatforms.indexOf(platform) !== -1
os = 'macOS'; // For new IPads with M1 chip and IPadOS platform returns "MacIntel"
} else if (iosPlatforms.indexOf(platform) !== -1) { || (platform === 'MacIntel' && ('maxTouchPoints' in navigator && navigator.maxTouchPoints > 2))) {
os = 'iOS'; os = 'iOS';
} else if (macosPlatforms.indexOf(platform) !== -1) {
os = 'macOS';
} else if (windowsPlatforms.indexOf(platform) !== -1) { } else if (windowsPlatforms.indexOf(platform) !== -1) {
os = 'Windows'; os = 'Windows';
} else if (/Android/.test(userAgent)) { } else if (/Android/.test(userAgent)) {

View File

@ -9,4 +9,8 @@ const safePlay = (mediaEl: HTMLMediaElement) => {
}); });
}; };
export const getIsVideoPlaying = (video: HTMLVideoElement) => {
return video.currentTime > 0 && !video.paused && !video.ended && video.readyState > 2;
};
export default safePlay; export default safePlay;