Media Viewer: Introduce Picture-in-Picture (#2015)
This commit is contained in:
parent
103e5ed8e9
commit
de772bfd59
Binary file not shown.
Binary file not shown.
@ -37,6 +37,7 @@ import { renderMessageText } from '../common/helpers/renderMessageText';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
|
||||
import { exitPictureInPictureIfNeeded } from '../../hooks/usePictureInPicture';
|
||||
import useLang from '../../hooks/useLang';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
import { useMediaProps } from './hooks/useMediaProps';
|
||||
@ -62,6 +63,7 @@ type StateProps = {
|
||||
message?: ApiMessage;
|
||||
chatMessages?: Record<number, ApiMessage>;
|
||||
collectionIds?: number[];
|
||||
isHidden?: boolean;
|
||||
animationLevel: AnimationLevel;
|
||||
shouldSkipHistoryAnimations?: boolean;
|
||||
};
|
||||
@ -80,6 +82,7 @@ const MediaViewer: FC<StateProps> = ({
|
||||
chatMessages,
|
||||
collectionIds,
|
||||
animationLevel,
|
||||
isHidden,
|
||||
shouldSkipHistoryAnimations,
|
||||
}) => {
|
||||
const {
|
||||
@ -120,6 +123,7 @@ const MediaViewer: FC<StateProps> = ({
|
||||
});
|
||||
|
||||
const canReport = !!avatarPhoto && !isChatWithSelf;
|
||||
const isVisible = !isHidden && isOpen;
|
||||
|
||||
/* Navigation */
|
||||
const singleMediaId = webPagePhoto || webPageVideo ? mediaId : undefined;
|
||||
@ -140,6 +144,12 @@ const MediaViewer: FC<StateProps> = ({
|
||||
animationKey.current = selectedMediaIndex;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
exitPictureInPictureIfNeeded();
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_SINGLE_COLUMN_LAYOUT) return;
|
||||
document.body.classList.toggle('is-media-viewer-open', isOpen);
|
||||
@ -164,14 +174,17 @@ const MediaViewer: FC<StateProps> = ({
|
||||
}, [forceUpdate]);
|
||||
|
||||
const prevMessage = usePrevious<ApiMessage | undefined>(message);
|
||||
const prevIsHidden = usePrevious<boolean | undefined>(isHidden);
|
||||
const prevOrigin = usePrevious(origin);
|
||||
const prevMediaId = usePrevious(mediaId);
|
||||
const prevAvatarOwner = usePrevious<ApiChat | ApiUser | undefined>(avatarOwner);
|
||||
const prevBestImageData = usePrevious(bestImageData);
|
||||
const textParts = message ? renderMessageText(message) : undefined;
|
||||
const hasFooter = Boolean(textParts);
|
||||
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId;
|
||||
|
||||
useEffect(() => {
|
||||
if (isGhostAnimation && isOpen && !prevMessage && !prevAvatarOwner) {
|
||||
if (isGhostAnimation && isOpen && (!prevMessage || shouldAnimateOpening) && !prevAvatarOwner) {
|
||||
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
|
||||
animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message);
|
||||
}
|
||||
@ -181,7 +194,7 @@ const MediaViewer: FC<StateProps> = ({
|
||||
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,
|
||||
]);
|
||||
|
||||
@ -269,7 +282,12 @@ const MediaViewer: FC<StateProps> = ({
|
||||
}
|
||||
|
||||
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}>
|
||||
{IS_SINGLE_COLUMN_LAYOUT && (
|
||||
<Button
|
||||
@ -323,6 +341,7 @@ const MediaViewer: FC<StateProps> = ({
|
||||
animationLevel={animationLevel}
|
||||
onClose={handleClose}
|
||||
selectMedia={selectMedia}
|
||||
isHidden={isHidden}
|
||||
onFooterClick={handleFooterClick}
|
||||
/>
|
||||
</ShowTransition>
|
||||
@ -337,6 +356,7 @@ export default memo(withGlobal(
|
||||
mediaId,
|
||||
avatarOwnerId,
|
||||
origin,
|
||||
isHidden,
|
||||
} = global.mediaViewer;
|
||||
const {
|
||||
animationLevel,
|
||||
@ -364,6 +384,7 @@ export default memo(withGlobal(
|
||||
origin,
|
||||
message,
|
||||
animationLevel,
|
||||
isHidden,
|
||||
shouldSkipHistoryAnimations,
|
||||
};
|
||||
}
|
||||
@ -380,6 +401,7 @@ export default memo(withGlobal(
|
||||
animationLevel,
|
||||
origin,
|
||||
shouldSkipHistoryAnimations,
|
||||
isHidden,
|
||||
};
|
||||
}
|
||||
|
||||
@ -426,6 +448,7 @@ export default memo(withGlobal(
|
||||
chatMessages,
|
||||
collectionIds,
|
||||
animationLevel,
|
||||
isHidden,
|
||||
shouldSkipHistoryAnimations,
|
||||
};
|
||||
},
|
||||
|
||||
@ -49,6 +49,7 @@ type StateProps = {
|
||||
isProtected?: boolean;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isHidden?: boolean;
|
||||
playbackRate: number;
|
||||
};
|
||||
|
||||
@ -68,6 +69,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
||||
volume,
|
||||
playbackRate,
|
||||
isMuted,
|
||||
isHidden,
|
||||
onClose,
|
||||
onFooterClick,
|
||||
setControlsVisible,
|
||||
@ -170,6 +172,7 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
|
||||
noPlay={!isActive}
|
||||
onClose={onClose}
|
||||
isMuted={isMuted}
|
||||
isHidden={isHidden}
|
||||
isProtected={isProtected}
|
||||
volume={volume}
|
||||
playbackRate={playbackRate}
|
||||
@ -202,6 +205,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
volume,
|
||||
isMuted,
|
||||
playbackRate,
|
||||
isHidden,
|
||||
} = global.mediaViewer;
|
||||
|
||||
if (origin === MediaViewerOrigin.SearchResult) {
|
||||
@ -223,6 +227,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
isProtected: selectIsMessageProtected(global, message),
|
||||
volume,
|
||||
isMuted,
|
||||
isHidden,
|
||||
playbackRate,
|
||||
};
|
||||
}
|
||||
@ -237,6 +242,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
origin,
|
||||
volume,
|
||||
isMuted,
|
||||
isHidden,
|
||||
playbackRate,
|
||||
};
|
||||
}
|
||||
@ -266,6 +272,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
isProtected: selectIsMessageProtected(global, message),
|
||||
volume,
|
||||
isMuted,
|
||||
isHidden,
|
||||
playbackRate,
|
||||
};
|
||||
},
|
||||
|
||||
@ -41,6 +41,7 @@ type OwnProps = {
|
||||
origin?: MediaViewerOrigin;
|
||||
animationLevel: AnimationLevel;
|
||||
onClose: () => void;
|
||||
isHidden?: boolean;
|
||||
hasFooter?: boolean;
|
||||
onFooterClick: () => void;
|
||||
zoomLevelChange: number;
|
||||
@ -83,6 +84,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
hasFooter,
|
||||
zoomLevelChange,
|
||||
animationLevel,
|
||||
isHidden,
|
||||
...rest
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
@ -139,7 +141,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
useTimeout(() => setControlsVisible(true), ANIMATION_DURATION + 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || activeMediaId === undefined) {
|
||||
setActiveMediaId(mediaId);
|
||||
}, [mediaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || activeMediaId === undefined || isHidden) {
|
||||
return undefined;
|
||||
}
|
||||
let lastTransform = lastTransformRef.current;
|
||||
@ -624,10 +630,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
clearSwipeDirectionDebounced,
|
||||
animationLevel,
|
||||
setIsMouseDown,
|
||||
isHidden,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !hasZoomChanged) return;
|
||||
if (!containerRef.current || !hasZoomChanged || isHidden) return;
|
||||
const { scale } = transformRef.current;
|
||||
const dir = zoomLevelChange > 0 ? -1 : +1;
|
||||
const minZoom = MIN_ZOOM * 0.6;
|
||||
@ -655,7 +662,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
|
||||
containerRef.current.dispatchEvent(wheelEvent);
|
||||
},
|
||||
});
|
||||
}, [zoomLevelChange, hasZoomChanged]);
|
||||
}, [zoomLevelChange, hasZoomChanged, isHidden]);
|
||||
|
||||
if (activeMediaId === undefined) return undefined;
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import type { ApiDimensions } from '../../api/types';
|
||||
|
||||
import useBuffering from '../../hooks/useBuffering';
|
||||
import useFullscreenStatus from '../../hooks/useFullscreen';
|
||||
import usePictureInPicture from '../../hooks/usePictureInPicture';
|
||||
import useShowTransition from '../../hooks/useShowTransition';
|
||||
import useVideoCleanup from '../../hooks/useVideoCleanup';
|
||||
import { IS_IOS, IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../util/environment';
|
||||
@ -31,6 +32,7 @@ type OwnProps = {
|
||||
noPlay?: boolean;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isHidden?: boolean;
|
||||
playbackRate: number;
|
||||
isProtected?: boolean;
|
||||
areControlsVisible: boolean;
|
||||
@ -39,6 +41,7 @@ type OwnProps = {
|
||||
};
|
||||
|
||||
const MOBILE_VERSION_CONTROL_WIDTH = 400;
|
||||
const MAX_LOOP_DURATION = 30; // Seconds
|
||||
|
||||
const VideoPlayer: FC<OwnProps> = ({
|
||||
url,
|
||||
@ -61,12 +64,21 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
setMediaViewerVolume,
|
||||
setMediaViewerMuted,
|
||||
setMediaViewerPlaybackRate,
|
||||
setMediaViewerHidden,
|
||||
} = getActions();
|
||||
// eslint-disable-next-line no-null/no-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 [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(() => {
|
||||
toggleControls(true);
|
||||
@ -90,7 +102,7 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
const {
|
||||
shouldRender: shouldRenderPlayButton,
|
||||
transitionClassNames: playButtonClassNames,
|
||||
} = useShowTransition(IS_IOS && !isPlayed && !shouldRenderSpinner, undefined, undefined, 'slow');
|
||||
} = useShowTransition(IS_IOS && !isPlaying && !shouldRenderSpinner, undefined, undefined, 'slow');
|
||||
|
||||
useEffect(() => {
|
||||
if (noPlay || !isMediaViewerOpen) {
|
||||
@ -101,12 +113,12 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
// so we need to use `autoPlay` instead to allow pre-buffering.
|
||||
safePlay(videoRef.current!);
|
||||
}
|
||||
}, [noPlay, isMediaViewerOpen, url]);
|
||||
}, [noPlay, isMediaViewerOpen, url, setMediaViewerMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current!.currentTime === videoRef.current!.duration) {
|
||||
setCurrentTime(0);
|
||||
setIsPlayed(false);
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
setCurrentTime(videoRef.current!.currentTime);
|
||||
}
|
||||
@ -122,14 +134,14 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
|
||||
const togglePlayState = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isPlayed) {
|
||||
if (isPlaying) {
|
||||
videoRef.current!.pause();
|
||||
setIsPlayed(false);
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
safePlay(videoRef.current!);
|
||||
setIsPlayed(true);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [isPlayed]);
|
||||
}, [isPlaying]);
|
||||
|
||||
useVideoCleanup(videoRef, []);
|
||||
|
||||
@ -139,7 +151,7 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
setCurrentTime(0);
|
||||
setIsPlayed(false);
|
||||
setIsPlaying(false);
|
||||
toggleControls(true);
|
||||
}, [toggleControls]);
|
||||
|
||||
@ -160,6 +172,8 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
}, [setMediaViewerVolume]);
|
||||
|
||||
const handleVolumeMuted = useCallback(() => {
|
||||
// Browser requires explicit user interaction to keep video playing after unmuting
|
||||
videoRef.current!.muted = !videoRef.current!.muted;
|
||||
setMediaViewerMuted({ isMuted: !isMuted });
|
||||
}, [isMuted, setMediaViewerMuted]);
|
||||
|
||||
@ -185,6 +199,7 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
|
||||
const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`;
|
||||
const videoStyle = `background-image: url(${posterData})`;
|
||||
const duration = videoRef.current?.duration || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -209,17 +224,21 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
autoPlay={IS_TOUCH_ENV}
|
||||
controlsList={isProtected ? 'nodownload' : undefined}
|
||||
playsInline
|
||||
loop={isGif}
|
||||
// This is to force auto playing on mobiles
|
||||
loop={isGif || duration <= MAX_LOOP_DURATION}
|
||||
// This is to force autoplaying on mobiles
|
||||
muted={isGif || isMuted}
|
||||
id="media-viewer-video"
|
||||
style={videoStyle}
|
||||
onPlay={IS_IOS ? () => setIsPlayed(true) : undefined}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onEnded={handleEnded}
|
||||
onClick={!IS_SINGLE_COLUMN_LAYOUT ? togglePlayState : undefined}
|
||||
onDoubleClick={!IS_TOUCH_ENV ? handleFullscreenChange : undefined}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...bufferingHandlers}
|
||||
onPause={(e) => {
|
||||
setIsPlaying(false);
|
||||
bufferingHandlers.onPause(e);
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
>
|
||||
{url && <source src={url} />}
|
||||
@ -243,20 +262,22 @@ const VideoPlayer: FC<OwnProps> = ({
|
||||
)}
|
||||
{!isGif && !shouldRenderSpinner && (
|
||||
<VideoPlayerControls
|
||||
isPlayed={isPlayed}
|
||||
isPlaying={isPlaying}
|
||||
bufferedRanges={bufferedRanges}
|
||||
bufferedProgress={bufferedProgress}
|
||||
isBuffered={isBuffered}
|
||||
currentTime={currentTime}
|
||||
isFullscreenSupported={Boolean(setFullscreen)}
|
||||
isPictureInPictureSupported={isPictureInPictureSupported}
|
||||
isFullscreen={isFullscreen}
|
||||
fileSize={fileSize}
|
||||
duration={videoRef.current ? videoRef.current.duration || 0 : 0}
|
||||
duration={duration}
|
||||
isVisible={areControlsVisible}
|
||||
setVisibility={toggleControls}
|
||||
isForceMobileVersion={posterSize && posterSize.width < MOBILE_VERSION_CONTROL_WIDTH}
|
||||
onSeek={handleSeek}
|
||||
onChangeFullscreen={handleFullscreenChange}
|
||||
onPictureInPictureChange={enterPictureInPicture}
|
||||
onPlayPause={togglePlayState}
|
||||
volume={volume}
|
||||
playbackRate={playbackRate}
|
||||
|
||||
@ -26,8 +26,9 @@ type OwnProps = {
|
||||
duration: number;
|
||||
fileSize: number;
|
||||
isForceMobileVersion?: boolean;
|
||||
isPlayed: boolean;
|
||||
isPlaying: boolean;
|
||||
isFullscreenSupported: boolean;
|
||||
isPictureInPictureSupported: boolean;
|
||||
isFullscreen: boolean;
|
||||
isVisible: boolean;
|
||||
isBuffered: boolean;
|
||||
@ -35,6 +36,7 @@ type OwnProps = {
|
||||
isMuted: boolean;
|
||||
playbackRate: number;
|
||||
onChangeFullscreen: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onPictureInPictureChange?: () => void ;
|
||||
onVolumeClick: () => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onPlaybackRateChange: (playbackRate: number) => void;
|
||||
@ -63,7 +65,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
duration,
|
||||
fileSize,
|
||||
isForceMobileVersion,
|
||||
isPlayed,
|
||||
isPlaying,
|
||||
isFullscreenSupported,
|
||||
isFullscreen,
|
||||
isVisible,
|
||||
@ -75,6 +77,8 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
onVolumeClick,
|
||||
onVolumeChange,
|
||||
onPlaybackRateChange,
|
||||
isPictureInPictureSupported,
|
||||
onPictureInPictureChange,
|
||||
onPlayPause,
|
||||
setVisibility,
|
||||
onSeek,
|
||||
@ -88,7 +92,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
useEffect(() => {
|
||||
if (!IS_TOUCH_ENV) return undefined;
|
||||
let timeout: number | undefined;
|
||||
if (!isVisible || !isPlayed || isSeeking || isPlaybackMenuOpen) {
|
||||
if (!isVisible || !isPlaying || isSeeking || isPlaybackMenuOpen) {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
return undefined;
|
||||
}
|
||||
@ -98,7 +102,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
return () => {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
};
|
||||
}, [isPlayed, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen]);
|
||||
}, [isPlaying, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
@ -172,7 +176,7 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
round
|
||||
onClick={onPlayPause}
|
||||
>
|
||||
<i className={isPlayed ? 'icon-pause' : 'icon-play'} />
|
||||
<i className={isPlaying ? 'icon-pause' : 'icon-play'} />
|
||||
</Button>
|
||||
<Button
|
||||
ariaLabel="Volume"
|
||||
@ -204,6 +208,18 @@ const VideoPlayerControls: FC<OwnProps> = ({
|
||||
>
|
||||
{`${playbackRate}x`}
|
||||
</Button>
|
||||
{isPictureInPictureSupported && (
|
||||
<Button
|
||||
ariaLabel="Picture in picture"
|
||||
size="tiny"
|
||||
color="translucent-white"
|
||||
className="fullscreen"
|
||||
round
|
||||
onClick={onPictureInPictureChange}
|
||||
>
|
||||
<i className="icon-pip" />
|
||||
</Button>
|
||||
)}
|
||||
{isFullscreenSupported && (
|
||||
<Button
|
||||
ariaLabel="Fullscreen"
|
||||
|
||||
@ -8,6 +8,7 @@ import buildClassName from '../../util/buildClassName';
|
||||
type OwnProps = {
|
||||
isOpen: boolean;
|
||||
isCustom?: boolean;
|
||||
isHidden?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
@ -17,18 +18,19 @@ type OwnProps = {
|
||||
|
||||
const ShowTransition: FC<OwnProps> = ({
|
||||
isOpen,
|
||||
isHidden,
|
||||
isCustom,
|
||||
id,
|
||||
className,
|
||||
onClick,
|
||||
noCloseTransition,
|
||||
children,
|
||||
noCloseTransition,
|
||||
}) => {
|
||||
const {
|
||||
shouldRender,
|
||||
transitionClassNames,
|
||||
} = useShowTransition(
|
||||
isOpen, undefined, undefined, isCustom ? false : undefined, noCloseTransition,
|
||||
isOpen && !isHidden, undefined, undefined, isCustom ? false : undefined, noCloseTransition,
|
||||
);
|
||||
const prevIsOpen = usePrevious(isOpen);
|
||||
const prevChildren = usePrevious(children);
|
||||
@ -39,7 +41,7 @@ const ShowTransition: FC<OwnProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
shouldRender && (
|
||||
(shouldRender || isHidden) && (
|
||||
<div id={id} className={buildClassName(className, transitionClassNames)} onClick={onClick}>
|
||||
{isOpen ? children : fromChildrenRef.current!}
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ addActionHandler('openMediaViewer', (global, actions, payload) => {
|
||||
avatarOwnerId,
|
||||
profilePhotoIndex,
|
||||
origin,
|
||||
isHidden: false,
|
||||
volume: volume ?? global.mediaViewer.volume,
|
||||
playbackRate: playbackRate || global.mediaViewer.playbackRate,
|
||||
isMuted: isMuted || global.mediaViewer.isMuted,
|
||||
@ -24,12 +25,15 @@ addActionHandler('openMediaViewer', (global, actions, payload) => {
|
||||
});
|
||||
|
||||
addActionHandler('closeMediaViewer', (global) => {
|
||||
const { volume, isMuted, playbackRate } = global.mediaViewer;
|
||||
const {
|
||||
volume, isMuted, playbackRate, isHidden,
|
||||
} = global.mediaViewer;
|
||||
return {
|
||||
...global,
|
||||
mediaViewer: {
|
||||
volume,
|
||||
isMuted,
|
||||
isHidden,
|
||||
playbackRate,
|
||||
},
|
||||
};
|
||||
@ -77,3 +81,15 @@ addActionHandler('setMediaViewerMuted', (global, actions, payload) => {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
addActionHandler('setMediaViewerHidden', (global, actions, payload) => {
|
||||
const isHidden = payload;
|
||||
|
||||
return {
|
||||
...global,
|
||||
mediaViewer: {
|
||||
...global.mediaViewer,
|
||||
isHidden,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -425,6 +425,7 @@ export type GlobalState = {
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
isMuted: boolean;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
|
||||
audioPlayer: {
|
||||
@ -803,7 +804,7 @@ export interface ActionPayloads {
|
||||
setMediaViewerMuted: {
|
||||
isMuted: boolean;
|
||||
};
|
||||
|
||||
setMediaViewerHidden: boolean;
|
||||
openAudioPlayer: {
|
||||
chatId: string;
|
||||
threadId?: number;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useLayoutEffect, useState } from '../lib/teact/teact';
|
||||
import { PLATFORM_ENV } from '../util/environment';
|
||||
import { IS_IOS } from '../util/environment';
|
||||
|
||||
type RefType = {
|
||||
current: HTMLElement | null;
|
||||
current: HTMLVideoElement | null;
|
||||
};
|
||||
|
||||
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 setFullscreen = () => {
|
||||
if (!elRef.current || !(prop || PLATFORM_ENV === 'iOS')) {
|
||||
if (!elRef.current || !(prop || IS_IOS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
safeRequestFullscreen(elRef.current);
|
||||
setIsFullscreen(true);
|
||||
};
|
||||
|
||||
@ -35,17 +25,7 @@ export default function useFullscreenStatus(elRef: RefType, setIsPlayed: Callbac
|
||||
if (!elRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.webkitCancelFullScreen) {
|
||||
document.webkitCancelFullScreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
|
||||
safeExitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
};
|
||||
|
||||
@ -79,7 +59,7 @@ export default function useFullscreenStatus(elRef: RefType, setIsPlayed: Callbac
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
if (!prop && PLATFORM_ENV !== 'iOS') {
|
||||
if (!prop && !IS_IOS) {
|
||||
return [false];
|
||||
}
|
||||
|
||||
@ -97,3 +77,27 @@ function getBrowserFullscreenElementProp() {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
107
src/hooks/usePictureInPicture.ts
Normal file
107
src/hooks/usePictureInPicture.ts
Normal 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
@ -51,6 +51,9 @@
|
||||
.icon-volume-3:before {
|
||||
content: "\e991";
|
||||
}
|
||||
.icon-pip:before {
|
||||
content: "\e9ae";
|
||||
}
|
||||
.icon-gift:before {
|
||||
content: "\e9ad";
|
||||
}
|
||||
|
||||
@ -21,10 +21,12 @@ export function getPlatform() {
|
||||
const iosPlatforms = ['iPhone', 'iPad', 'iPod'];
|
||||
let os: 'macOS' | 'iOS' | 'Windows' | 'Android' | 'Linux' | undefined;
|
||||
|
||||
if (macosPlatforms.indexOf(platform) !== -1) {
|
||||
os = 'macOS';
|
||||
} else if (iosPlatforms.indexOf(platform) !== -1) {
|
||||
if (iosPlatforms.indexOf(platform) !== -1
|
||||
// For new IPads with M1 chip and IPadOS platform returns "MacIntel"
|
||||
|| (platform === 'MacIntel' && ('maxTouchPoints' in navigator && navigator.maxTouchPoints > 2))) {
|
||||
os = 'iOS';
|
||||
} else if (macosPlatforms.indexOf(platform) !== -1) {
|
||||
os = 'macOS';
|
||||
} else if (windowsPlatforms.indexOf(platform) !== -1) {
|
||||
os = 'Windows';
|
||||
} else if (/Android/.test(userAgent)) {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user