From 8535fcff669716a6e30e145cfcace2ffb8bcc602 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 25 Apr 2023 17:24:18 +0400 Subject: [PATCH] Media Viewer: Introduce Video Previews (#2689) --- .eslintignore | 2 + deploy/copy_to_dist.sh | 2 + package-lock.json | 19 +- package.json | 1 + src/components/mediaViewer/MediaViewer.tsx | 2 +- .../mediaViewer/MediaViewerContent.tsx | 10 +- .../mediaViewer/MediaViewerSlides.tsx | 4 +- .../mediaViewer/SeekLine.module.scss | 93 ++ src/components/mediaViewer/SeekLine.tsx | 233 +++++ src/components/mediaViewer/VideoPlayer.scss | 7 +- src/components/mediaViewer/VideoPlayer.tsx | 51 +- .../mediaViewer/VideoPlayerControls.scss | 53 +- .../mediaViewer/VideoPlayerControls.tsx | 114 +-- .../mediaViewer/hooks/currentTimeSignal.ts | 13 + .../mediaViewer/hooks/useMediaProps.ts | 41 +- src/components/ui/ShowTransition.tsx | 12 +- src/hooks/useBuffering.ts | 3 + src/lib/gramjs/Utils.js | 3 + src/lib/gramjs/client/downloadFile.ts | 8 +- src/lib/mediaWorker/index.worker.ts | 9 + src/lib/rlottie/RLottie.ts | 17 +- src/lib/rlottie/rlottie.worker.ts | 8 +- src/lib/video-preview/MP4Demuxer.ts | 214 +++++ src/lib/video-preview/VideoPreview.ts | 128 +++ .../libav-3.10.5.1.2-webcodecs.js | 1 + .../libav-3.10.5.1.2-webcodecs.wasm.js | 814 +++++++++++++++++ .../libav-3.10.5.1.2-webcodecs.wasm.wasm | Bin 0 -> 2744040 bytes src/lib/video-preview/libav.types.d.ts | 848 ++++++++++++++++++ src/lib/video-preview/mp4box.d.ts | 93 ++ src/lib/video-preview/polyfill/config.ts | 73 ++ .../polyfill/encoded-audio-chunk.ts | 62 ++ .../polyfill/encoded-video-chunk.ts | 25 + src/lib/video-preview/polyfill/index.ts | 105 +++ src/lib/video-preview/polyfill/libav.ts | 136 +++ src/lib/video-preview/polyfill/misc.ts | 35 + src/lib/video-preview/polyfill/rendering.ts | 190 ++++ .../video-preview/polyfill/video-decoder.ts | 423 +++++++++ src/lib/video-preview/polyfill/video-frame.ts | 822 +++++++++++++++++ src/lib/video-preview/requestPart.ts | 62 ++ src/lib/video-preview/video-preview.worker.ts | 88 ++ src/serviceWorker/download.ts | 4 +- src/util/PostMessageConnector.ts | 2 +- src/util/createPostMessageInterface.ts | 11 +- src/util/launchMediaWorkers.ts | 25 + src/util/windowEnvironment.ts | 2 + webpack.config.js | 3 + 46 files changed, 4675 insertions(+), 196 deletions(-) create mode 100644 src/components/mediaViewer/SeekLine.module.scss create mode 100644 src/components/mediaViewer/SeekLine.tsx create mode 100644 src/components/mediaViewer/hooks/currentTimeSignal.ts create mode 100644 src/lib/mediaWorker/index.worker.ts create mode 100644 src/lib/video-preview/MP4Demuxer.ts create mode 100644 src/lib/video-preview/VideoPreview.ts create mode 100644 src/lib/video-preview/libav-3.10.5.1.2-webcodecs.js create mode 100644 src/lib/video-preview/libav-3.10.5.1.2-webcodecs.wasm.js create mode 100644 src/lib/video-preview/libav-3.10.5.1.2-webcodecs.wasm.wasm create mode 100644 src/lib/video-preview/libav.types.d.ts create mode 100644 src/lib/video-preview/mp4box.d.ts create mode 100644 src/lib/video-preview/polyfill/config.ts create mode 100644 src/lib/video-preview/polyfill/encoded-audio-chunk.ts create mode 100644 src/lib/video-preview/polyfill/encoded-video-chunk.ts create mode 100644 src/lib/video-preview/polyfill/index.ts create mode 100644 src/lib/video-preview/polyfill/libav.ts create mode 100644 src/lib/video-preview/polyfill/misc.ts create mode 100644 src/lib/video-preview/polyfill/rendering.ts create mode 100644 src/lib/video-preview/polyfill/video-decoder.ts create mode 100644 src/lib/video-preview/polyfill/video-frame.ts create mode 100644 src/lib/video-preview/requestPart.ts create mode 100644 src/lib/video-preview/video-preview.worker.ts create mode 100644 src/util/launchMediaWorkers.ts diff --git a/.eslintignore b/.eslintignore index d9fde8543..a7026c0e3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,7 @@ src/lib/rlottie/rlottie-wasm.js +src/lib/video-preview/libav* +src/lib/video-preview/polyfill/* src/lib/webp/webp_wasm.js src/lib/fasttextweb/fasttext-wasm.js diff --git a/deploy/copy_to_dist.sh b/deploy/copy_to_dist.sh index b74b4bb30..9e03257a2 100755 --- a/deploy/copy_to_dist.sh +++ b/deploy/copy_to_dist.sh @@ -3,6 +3,8 @@ cp -R ./public/* ${1:-"dist"} cp ./src/lib/rlottie/rlottie-wasm.wasm ${1:-"dist"} +cp ./src/lib/video-preview/libav-3.10.5.1.2-webcodecs.wasm.js ${1:-"dist"} +cp ./src/lib/video-preview/libav-3.10.5.1.2-webcodecs.wasm.wasm ${1:-"dist"} cp ./src/lib/webp/webp_wasm.wasm ${1:-"dist"} cp ./node_modules/opus-recorder/dist/decoderWorker.min.wasm ${1:-"dist"} diff --git a/package-lock.json b/package-lock.json index 6be7b9678..d183bf7fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#3c401da02c6792cea0b6c758795da406377bf667", "idb-keyval": "^6.2.0", "lowlight": "^2.8.1", + "mp4box": "^0.5.2", "opus-recorder": "github:Ajaxy/opus-recorder", "os-browserify": "^0.3.0", "pako": "^2.1.0", @@ -106,7 +107,6 @@ } }, "dev/eslint-multitab": { - "name": "eslint-plugin-eslint-multitab-tt", "version": "0.0.0", "dev": true, "license": "ISC", @@ -7438,9 +7438,9 @@ } }, "node_modules/eslint-doc-generator/node_modules/cosmiconfig": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.0.tgz", - "integrity": "sha512-0tLZ9URlPGU7JsKq0DQOQ3FoRsYX8xDZ7xMiATQfaiGMz7EHowNkbU9u1coAOmnh9p/1ySpm0RB3JNWRXM5GCg==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", "dev": true, "dependencies": { "import-fresh": "^3.2.1", @@ -13151,6 +13151,11 @@ "node": ">=10" } }, + "node_modules/mp4box": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.2.tgz", + "integrity": "sha512-zRmGlvxy+YdW3Dmt+TR4xPHynbxwXtAQDTN/Fo9N3LMxaUlB2C5KmZpzYyGKy4c7k4Jf3RCR0A2pm9SZELOLXw==" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -16699,9 +16704,9 @@ } }, "node_modules/type-fest": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.6.1.tgz", - "integrity": "sha512-htXWckxlT6U4+ilVgweNliPqlsVSSucbxVexRYllyMVJDtf5rTjv6kF/s+qAd4QSL1BZcnJPEJavYBPQiWuZDA==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.8.0.tgz", + "integrity": "sha512-FVNSzGQz9Th+/9R6Lvv7WIAkstylfHN2/JYxkyhhmKFYh9At2DST8t6L6Lref9eYO8PXFTfG9Sg1Agg0K3vq3Q==", "dev": true, "engines": { "node": ">=14.16" diff --git a/package.json b/package.json index c0a9a3798..570e2f4d4 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "emoji-data-ios": "git+https://github.com/korenskoy/emoji-data-ios#3c401da02c6792cea0b6c758795da406377bf667", "idb-keyval": "^6.2.0", "lowlight": "^2.8.1", + "mp4box": "^0.5.2", "opus-recorder": "github:Ajaxy/opus-recorder", "os-browserify": "^0.3.0", "pako": "^2.1.0", diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 5a6b4d95f..30e2f0a9a 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -213,7 +213,7 @@ const MediaViewer: FC = ({ useEffect(() => { if (isGhostAnimation && isOpen && (!prevMessage || shouldAnimateOpening) && !prevAvatarOwner) { dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); - animateOpening(hasFooter, origin!, bestImageData!, dimensions, isVideo, message); + animateOpening(hasFooter, origin!, bestImageData!, dimensions!, isVideo, message); } if (isGhostAnimation && !isOpen && (prevMessage || prevAvatarOwner)) { diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index eb6fc9908..289589c28 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -8,7 +8,9 @@ import type { import type { AnimationLevel } from '../../types'; import { MediaViewerOrigin } from '../../types'; -import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import { + IS_TOUCH_ENV, IS_IOS, IS_ANDROID, ARE_WEBCODECS_SUPPORTED, +} from '../../util/windowEnvironment'; import { selectChat, selectChatMessage, selectTabState, selectIsMessageProtected, selectScheduledMessage, selectUser, } from '../../global/selectors'; @@ -57,6 +59,7 @@ type StateProps = { const ANIMATION_DURATION = 350; const MOBILE_VERSION_CONTROL_WIDTH = 350; +const IS_PREVIEW_DISABLED = (IS_IOS || IS_ANDROID) && !ARE_WEBCODECS_SUPPORTED; const MediaViewerContent: FC = (props) => { const { @@ -89,6 +92,7 @@ const MediaViewerContent: FC = (props) => { bestData, dimensions, isGif, + isLocal, isVideoAvatar, videoSize, loadProgress, @@ -111,7 +115,7 @@ const MediaViewerContent: FC = (props) => {
{renderPhoto( bestData, - calculateMediaViewerDimensions(dimensions, false), + calculateMediaViewerDimensions(dimensions!, false), !isMobile && !isProtected, isProtected, )} @@ -130,6 +134,7 @@ const MediaViewerContent: FC = (props) => { fileSize={videoSize!} isMediaViewerOpen={isOpen && isActive} isProtected={isProtected} + isPreviewDisabled={IS_PREVIEW_DISABLED || isLocal} noPlay={!isActive} onClose={onClose} isMuted @@ -179,6 +184,7 @@ const MediaViewerContent: FC = (props) => { fileSize={videoSize!} isMediaViewerOpen={isOpen && isActive} noPlay={!isActive} + isPreviewDisabled={IS_PREVIEW_DISABLED || isLocal} onClose={onClose} isMuted={isMuted} isHidden={isHidden} diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index 96ddb5efd..697b85dbb 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -409,7 +409,7 @@ const MediaViewerSlides: FC = ({ const cleanup = captureEvents(containerRef.current, { isNotPassive: true, withNativeDrag: true, - excludedClosestSelector: '.MediaViewerFooter, .ZoomControls', + excludedClosestSelector: '.MediaViewerFooter, .ZoomControls, .VideoPlayerControls', minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM, doubleTapZoom: DOUBLE_TAP_ZOOM, @@ -775,7 +775,7 @@ function checkIfControlTarget(e: TouchEvent | MouseEvent) { if (checkIfInsideSelector(target, '.VideoPlayerControls')) { if (checkIfInsideSelector( target, - '.play, .fullscreen, .volume, .volume-slider, .playback-rate, .playback-rate-menu', + '.play, .fullscreen, .volume, .volume-slider, .playback-rate, .playback-rate-menu, .SeekLine', )) { return true; } diff --git a/src/components/mediaViewer/SeekLine.module.scss b/src/components/mediaViewer/SeekLine.module.scss new file mode 100644 index 000000000..f69ccfbed --- /dev/null +++ b/src/components/mediaViewer/SeekLine.module.scss @@ -0,0 +1,93 @@ +.container { + position: absolute; + left: 1rem; + right: 1rem; + top: 1rem; + height: 1rem; + touch-action: none; + cursor: pointer; +} + +.preview { + position: absolute; + left: 0; + z-index: 1; + bottom: calc(100% + 0.5rem); + border-radius: 0.25rem; + overflow: hidden; + background: #000; +} + +.previewCanvas { + width: 100%; + height: 100%; + display: block; +} + +body:global(.is-touch-env) .preview { + bottom: calc(100% + 0.75rem); +} + +.previewTime { + position: absolute; + left: 0; + right: 0; + bottom: 0; + text-align: center; +} + +.previewTimeText { + border-top-left-radius: 0.125rem; + border-top-right-radius: 0.125rem; + background: rgba(0, 0, 0, 0.5); + color: rgba(255, 255, 255, 0.8); + padding: 0.25rem 0.5rem; +} + +.track { + position: absolute; + top: 50%; + height: 5px; + transform: translateY(-50%); + background-color: rgba(255, 255, 255, 0.16); + border-radius: var(--border-radius-default); + left: -0.25rem; + right: -0.25rem; +} + +.buffered { + position: absolute; + background-color: rgba(255, 255, 255, 0.5); + top: 0; + left: 0; + height: 100%; + border-radius: var(--border-radius-default); +} + +.played { + background: var(--color-primary); + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: var(--border-radius-default); + + &::after { + content: ""; + position: absolute; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background-color: var(--color-primary); + right: 0; + top: 50%; + transform: translate(50%, -50%) scale(1); + transition: transform 0.2s ease; + } +} + +body:global(.is-touch-env) .seeking { + &::after { + transform: translate(50%, -50%) scale(2); + } +} diff --git a/src/components/mediaViewer/SeekLine.tsx b/src/components/mediaViewer/SeekLine.tsx new file mode 100644 index 000000000..d2f1d03a5 --- /dev/null +++ b/src/components/mediaViewer/SeekLine.tsx @@ -0,0 +1,233 @@ +import React, { + useRef, useState, useCallback, useEffect, memo, useMemo, useLayoutEffect, +} from '../../lib/teact/teact'; + +import type { BufferedRange } from '../../hooks/useBuffering'; +import type { ApiDimensions } from '../../api/types'; + +import useSignal from '../../hooks/useSignal'; +import useCurrentTimeSignal from './hooks/currentTimeSignal'; + +import { captureEvents } from '../../util/captureEvents'; +import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import buildClassName from '../../util/buildClassName'; +import { formatMediaDuration } from '../../util/dateFormat'; +import { clamp, round } from '../../util/math'; + +import { createVideoPreviews, renderVideoPreview, getPreviewDimensions } from '../../lib/video-preview/VideoPreview'; + +import ShowTransition from '../ui/ShowTransition'; + +import styles from './SeekLine.module.scss'; + +type OwnProps = { + url?: string; + duration: number; + bufferedRanges: BufferedRange[]; + isActive?: boolean; + isPreviewDisabled?: boolean; + isReady: boolean; + posterSize?: ApiDimensions; + onSeek: (position: number) => void; + onSeekStart: () => void; +}; + +const SeekLine: React.FC = ({ + duration, + bufferedRanges, + isReady, + posterSize, + url, + isActive, + isPreviewDisabled, + onSeek, + onSeekStart, +}) => { + // eslint-disable-next-line no-null/no-null + const seekerRef = useRef(null); + const [getCurrentTime] = useCurrentTimeSignal(); + const [getSelectedTime, setSelectedTime] = useSignal(getCurrentTime()); + const [getPreviewOffset, setPreviewOffset] = useSignal(0); + const [getPreviewTime, setPreviewTime] = useSignal(0); + const isLockedRef = useRef(false); + const [isPreviewVisible, setPreviewVisible] = useState(false); + const [isSeeking, setIsSeeking] = useState(false); + // eslint-disable-next-line no-null/no-null + const previewCanvasRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const previewRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const progressRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const previewTimeRef = useRef(null); + + const previewSize = useMemo(() => { + return getPreviewDimensions(posterSize?.width || 0, posterSize?.height || 0); + }, [posterSize]); + + const setPreview = useCallback((time: number) => { + time = Math.floor(time); + setPreviewTime(time); + renderVideoPreview(time); + }, [setPreviewTime]); + + useEffect(() => { + if (isPreviewDisabled || !url || !isReady) return undefined; + return createVideoPreviews(url, previewCanvasRef.current!); + }, [url, isReady, isPreviewDisabled]); + + useEffect(() => { + setPreviewVisible(false); + }, [isActive]); + + useEffect(() => { + if (!isLockedRef.current && !isSeeking) { + setSelectedTime(getCurrentTime()); + } + }, [getCurrentTime, isSeeking, setSelectedTime]); + + useLayoutEffect(() => { + if (!progressRef.current) return; + const progress = round((getSelectedTime() / duration) * 100, 2); + progressRef.current.style.width = `${progress}%`; + }, [getSelectedTime, duration]); + + useLayoutEffect(() => { + if (!previewRef.current) return; + previewRef.current.style.left = `${getPreviewOffset()}px`; + }, [getPreviewOffset]); + + useLayoutEffect(() => { + if (!previewTimeRef.current) return; + previewTimeRef.current.innerText = formatMediaDuration(getPreviewTime()); + }, [getPreviewTime]); + + useEffect(() => { + if (!seekerRef.current || !isActive) return undefined; + const seeker = seekerRef.current; + let seekerSize = seeker.getBoundingClientRect(); + + let time = 0; + let offset = 0; + + const getPreviewProps = (e: MouseEvent | TouchEvent) => { + const pageX = e instanceof MouseEvent ? e.pageX : e.touches[0].pageX; + const t = clamp(duration * ((pageX - seekerSize.left) / seekerSize.width), 0, duration); + if (isPreviewDisabled) return [t, 0]; + if (!seekerSize.width) seekerSize = seeker.getBoundingClientRect(); + const preview = previewRef.current!; + const o = clamp( + pageX - seekerSize.left - preview.clientWidth / 2, -4, seekerSize.width - preview.clientWidth + 4, + ); + return [t, o]; + }; + + const handleSeek = (e: MouseEvent | TouchEvent) => { + setPreviewVisible(true); + ([time, offset] = getPreviewProps(e)); + void setPreview(time); + setPreviewOffset(offset); + setSelectedTime(time); + }; + + const handleStartSeek = () => { + setPreviewVisible(true); + setIsSeeking(true); + onSeekStart(); + }; + + const handleStopSeek = () => { + isLockedRef.current = true; + setPreviewVisible(false); + setIsSeeking(false); + setSelectedTime(time); + onSeek(time); + // Prevent current time updates from overriding the selected time + setTimeout(() => { + isLockedRef.current = false; + }, 500); + }; + + const cleanup = captureEvents(seeker, { + onCapture: handleStartSeek, + onRelease: handleStopSeek, + onClick: handleStopSeek, + onDrag: handleSeek, + }); + + if (IS_TOUCH_ENV || isPreviewDisabled) { + return cleanup; + } + + const handleSeekMouseMove = (e: MouseEvent) => { + setPreviewVisible(true); + ([time, offset] = getPreviewProps(e)); + setPreviewOffset(offset); + void setPreview(time); + }; + + const handleSeekMouseLeave = () => { + setPreviewVisible(false); + }; + + seeker.addEventListener('mousemove', handleSeekMouseMove); + seeker.addEventListener('mouseenter', handleSeekMouseMove); + seeker.addEventListener('mouseleave', handleSeekMouseLeave); + + return () => { + cleanup(); + seeker.removeEventListener('mousemove', handleSeekMouseMove); + seeker.removeEventListener('mouseenter', handleSeekMouseMove); + seeker.removeEventListener('mouseleave', handleSeekMouseLeave); + }; + }, [ + duration, + setPreview, + isActive, + onSeek, + onSeekStart, + setPreviewOffset, + setSelectedTime, + setIsSeeking, + isPreviewDisabled, + ]); + + return ( +
+ {!isPreviewDisabled && ( + + +
+ +
+
+ )} +
+ {bufferedRanges.map(({ + start, + end, + }) => ( +
+ ))} +
+
+
+
+
+ ); +}; + +export default memo(SeekLine); diff --git a/src/components/mediaViewer/VideoPlayer.scss b/src/components/mediaViewer/VideoPlayer.scss index ee94377f4..67bebfad1 100644 --- a/src/components/mediaViewer/VideoPlayer.scss +++ b/src/components/mediaViewer/VideoPlayer.scss @@ -3,12 +3,7 @@ display: inline-flex; flex-direction: column; overflow: hidden; - - @media (min-width: 601px) { - // Safari: custom controls are not displayed after exiting full screen mode. - z-index: 1; - } - + @media (max-width: 600px) { overflow: visible; } diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index cfa63b9ac..718dda98a 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -16,6 +16,7 @@ import usePictureInPicture from '../../hooks/usePictureInPicture'; import useShowTransition from '../../hooks/useShowTransition'; import useVideoCleanup from '../../hooks/useVideoCleanup'; import useAppLayout from '../../hooks/useAppLayout'; +import useCurrentTimeSignal from './hooks/currentTimeSignal'; import useControlsSignal from './hooks/useControlsSignal'; import Button from '../ui/Button'; @@ -31,6 +32,7 @@ type OwnProps = { posterSize?: ApiDimensions; loadProgress?: number; fileSize: number; + isPreviewDisabled?: boolean; isMediaViewerOpen?: boolean; noPlay?: boolean; volume: number; @@ -45,6 +47,7 @@ type OwnProps = { }; const MAX_LOOP_DURATION = 30; // Seconds +const MIN_READY_STATE = 4; const REWIND_STEP = 5; // Seconds const VideoPlayer: FC = ({ @@ -64,6 +67,7 @@ const VideoPlayer: FC = ({ shouldCloseOnClick, isProtected, isClickDisabled, + isPreviewDisabled, }) => { const { setMediaViewerVolume, @@ -74,9 +78,10 @@ const VideoPlayer: FC = ({ // eslint-disable-next-line no-null/no-null const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(!IS_TOUCH_ENV || !IS_IOS); - const [currentTime, setCurrentTime] = useState(0); const [isFullscreen, setFullscreen, exitFullscreen] = useFullscreen(videoRef, setIsPlaying); const { isMobile } = useAppLayout(); + const duration = videoRef.current?.duration || 0; + const isLooped = isGif || duration <= MAX_LOOP_DURATION; const handleEnterFullscreen = useCallback(() => { // Yandex browser doesn't support PIP when video is hidden @@ -95,7 +100,7 @@ const VideoPlayer: FC = ({ isInPictureInPicture, ] = usePictureInPicture(videoRef, handleEnterFullscreen, handleLeaveFullscreen); - const [, toggleControls] = useControlsSignal(); + const [, toggleControls, lockControls] = useControlsSignal(); const handleVideoMove = useCallback(() => { toggleControls(true); @@ -110,8 +115,9 @@ const VideoPlayer: FC = ({ }, [toggleControls]); const { - isBuffered, bufferedRanges, bufferingHandlers, bufferedProgress, + isReady, isBuffered, bufferedRanges, bufferingHandlers, bufferedProgress, } = useBuffering(); + const { shouldRender: shouldRenderSpinner, transitionClassNames: spinnerClassNames, @@ -121,6 +127,10 @@ const VideoPlayer: FC = ({ transitionClassNames: playButtonClassNames, } = useShowTransition(IS_IOS && !isPlaying && !shouldRenderSpinner, undefined, undefined, 'slow'); + useEffect(() => { + lockControls(shouldRenderSpinner); + }, [lockControls, shouldRenderSpinner]); + useEffect(() => { if (noPlay || !isMediaViewerOpen) { videoRef.current!.pause(); @@ -132,15 +142,6 @@ const VideoPlayer: FC = ({ } }, [noPlay, isMediaViewerOpen, url, setMediaViewerMuted]); - useEffect(() => { - if (videoRef.current!.currentTime === videoRef.current!.duration) { - setCurrentTime(0); - setIsPlaying(false); - } else { - setCurrentTime(videoRef.current!.currentTime); - } - }, [currentTime]); - useEffect(() => { videoRef.current!.volume = volume; }, [volume]); @@ -164,7 +165,6 @@ const VideoPlayer: FC = ({ if (isClickDisabled) { return; } - if (shouldCloseOnClick) { onClose(e); } else { @@ -173,16 +173,25 @@ const VideoPlayer: FC = ({ }, [onClose, shouldCloseOnClick, togglePlayState, isClickDisabled]); useVideoCleanup(videoRef, []); + const [, setCurrentTime] = useCurrentTimeSignal(); const handleTimeUpdate = useCallback((e: React.SyntheticEvent) => { - setCurrentTime(e.currentTarget.currentTime); - }, []); + const video = e.currentTarget; + if (video.readyState >= MIN_READY_STATE) { + setCurrentTime(video.currentTime); + } + if (!isLooped && video.currentTime === video.duration) { + setCurrentTime(0); + setIsPlaying(false); + } + }, [isLooped, setCurrentTime]); const handleEnded = useCallback(() => { + if (isLooped) return; setCurrentTime(0); setIsPlaying(false); toggleControls(true); - }, [toggleControls]); + }, [isLooped, setCurrentTime, toggleControls]); const handleFullscreenChange = useCallback(() => { if (isFullscreen && exitFullscreen) { @@ -251,7 +260,6 @@ const VideoPlayer: FC = ({ const wrapperStyle = posterSize && `width: ${posterSize.width}px; height: ${posterSize.height}px`; const videoStyle = `background-image: url(${posterData})`; const shouldToggleControls = !IS_TOUCH_ENV && !isForceMobileVersion; - const duration = videoRef.current?.duration || 0; return ( // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events @@ -277,7 +285,7 @@ const VideoPlayer: FC = ({ autoPlay={IS_TOUCH_ENV} controlsList="nodownload" playsInline - loop={isGif || duration <= MAX_LOOP_DURATION} + loop={isLooped} // This is to force autoplaying on mobiles muted={isGif || isMuted} id="media-viewer-video" @@ -313,18 +321,21 @@ const VideoPlayer: FC = ({ />
)} - {!isGif && !shouldRenderSpinner && ( + {!isGif && ( ) => void; onPictureInPictureChange?: () => void ; onVolumeClick: () => void; @@ -61,19 +68,22 @@ const PLAYBACK_RATES = [ const HIDE_CONTROLS_TIMEOUT_MS = 3000; const VideoPlayerControls: FC = ({ + url, bufferedRanges, bufferedProgress, - currentTime, duration, + isReady, fileSize, isForceMobileVersion, isPlaying, isFullscreenSupported, isFullscreen, isBuffered, + isPreviewDisabled, volume, isMuted, playbackRate, + posterSize, onChangeFullscreen, onVolumeClick, onVolumeChange, @@ -84,10 +94,9 @@ const VideoPlayerControls: FC = ({ onSeek, }) => { const [isPlaybackMenuOpen, openPlaybackMenu, closePlaybackMenu] = useFlag(); - // eslint-disable-next-line no-null/no-null - const seekerRef = useRef(null); - const isSeekingRef = useRef(false); - const isSeeking = isSeekingRef.current; + const [getCurrentTime] = useCurrentTimeSignal(); + const currentTime = useDerivedState(() => Math.trunc(getCurrentTime()), [getCurrentTime]); + const [getIsSeeking, setIsSeeking] = useSignal(false); const { isMobile } = useAppLayout(); const [getIsVisible, setVisibility] = useControlsSignal(); @@ -96,7 +105,7 @@ const VideoPlayerControls: FC = ({ useEffect(() => { if (!IS_TOUCH_ENV && !isForceMobileVersion) return undefined; let timeout: number | undefined; - if (!isVisible || !isPlaying || isSeeking || isPlaybackMenuOpen) { + if (!isVisible || !isPlaying || isPlaybackMenuOpen || getIsSeeking()) { if (timeout) window.clearTimeout(timeout); return undefined; } @@ -106,9 +115,9 @@ const VideoPlayerControls: FC = ({ return () => { if (timeout) window.clearTimeout(timeout); }; - }, [isPlaying, isVisible, isSeeking, setVisibility, isPlaybackMenuOpen, isForceMobileVersion]); + }, [isPlaying, isVisible, setVisibility, isPlaybackMenuOpen, getIsSeeking, isForceMobileVersion]); - useEffect(() => { + useLayoutEffect(() => { if (isVisible) { document.body.classList.add('video-controls-visible'); } else { @@ -127,35 +136,14 @@ const VideoPlayerControls: FC = ({ const lang = useLang(); - const handleSeek = useCallback((e: MouseEvent | TouchEvent) => { - if (isSeekingRef.current && seekerRef.current) { - const { - width, - left, - } = seekerRef.current.getBoundingClientRect(); - const clientX = e instanceof MouseEvent ? e.clientX : e.targetTouches[0].clientX; - onSeek(Math.max(Math.min(duration * ((clientX - left) / width), duration), 0)); - } - }, [duration, onSeek]); + const handleSeek = useCallback((position: number) => { + setIsSeeking(false); + onSeek(position); + }, [onSeek, setIsSeeking]); - const handleStartSeek = useCallback((e: MouseEvent | TouchEvent) => { - isSeekingRef.current = true; - handleSeek(e); - }, [handleSeek]); - - const handleStopSeek = useCallback(() => { - isSeekingRef.current = false; - }, []); - - useEffect(() => { - if (!seekerRef.current || !isVisible) return undefined; - return captureEvents(seekerRef.current, { - onCapture: handleStartSeek, - onRelease: handleStopSeek, - onClick: handleStopSeek, - onDrag: handleSeek, - }); - }, [isVisible, handleStartSeek, handleSeek, handleStopSeek]); + const handleSeekStart = useCallback(() => { + setIsSeeking(true); + }, [setIsSeeking]); const volumeIcon = useMemo(() => { if (volume === 0 || isMuted) return 'icon-muted'; @@ -169,7 +157,17 @@ const VideoPlayerControls: FC = ({ className={buildClassName('VideoPlayerControls', isForceMobileVersion && 'mobile', isVisible && 'active')} onClick={stopEvent} > - {renderSeekLine(currentTime, duration, bufferedRanges, seekerRef)} +