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 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,
};
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
content: "\e991";
}
.icon-pip:before {
content: "\e9ae";
}
.icon-gift:before {
content: "\e9ad";
}

View File

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

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;