290 lines
8.8 KiB
TypeScript

import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useLayoutEffect,
useMemo, useRef, useState,
} from '../../lib/teact/teact';
import type { ApiDimensions } from '../../api/types';
import type { BufferedRange } from '../../hooks/useBuffering';
import { createVideoPreviews, getPreviewDimensions, renderVideoPreview } from '../../lib/video-preview/VideoPreview';
import { animateNumber } from '../../util/animation';
import buildClassName from '../../util/buildClassName';
import { captureEvents } from '../../util/captureEvents';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import { clamp, round } from '../../util/math';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { useThrottledSignal } from '../../hooks/useAsyncResolvers';
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useLastCallback from '../../hooks/useLastCallback';
import useSignal from '../../hooks/useSignal';
import useVideoWaitingSignal from './hooks/useVideoWaitingSignal';
import ShowTransition from '../ui/ShowTransition';
import styles from './SeekLine.module.scss';
type OwnProps = {
url?: string;
duration: number;
bufferedRanges: BufferedRange[];
playbackRate: number;
isActive?: boolean;
isPlaying?: boolean;
isPreviewDisabled?: boolean;
isReady: boolean;
posterSize?: ApiDimensions;
onSeek: (position: number) => void;
onSeekStart: () => void;
};
const LOCK_TIMEOUT = 250;
let cancelAnimation: Function | undefined;
const SeekLine: FC<OwnProps> = ({
duration,
bufferedRanges,
isReady,
posterSize,
playbackRate,
url,
isActive,
isPlaying,
isPreviewDisabled,
onSeek,
onSeekStart,
}) => {
// eslint-disable-next-line no-null/no-null
const seekerRef = useRef<HTMLDivElement>(null);
const [getCurrentTimeSignal] = useCurrentTimeSignal();
const [getIsWaiting] = useVideoWaitingSignal();
const getCurrentTime = useThrottledSignal(getCurrentTimeSignal, LOCK_TIMEOUT);
const [getSelectedTime, setSelectedTime] = useSignal(getCurrentTime());
const [getPreviewOffset, setPreviewOffset] = useSignal(0);
const [getPreviewTime, setPreviewTime] = useSignal(0);
const isLockedRef = useRef<boolean>(false);
const [isPreviewVisible, setPreviewVisible] = useState(false);
const [isSeeking, setIsSeeking] = useState(false);
// eslint-disable-next-line no-null/no-null
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line no-null/no-null
const previewRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const progressRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const previewTimeRef = useRef<HTMLDivElement>(null);
const previewSize = useMemo(() => {
return getPreviewDimensions(posterSize?.width || 0, posterSize?.height || 0);
}, [posterSize]);
const setPreview = useLastCallback((time: number) => {
time = Math.floor(time);
setPreviewTime(time);
renderVideoPreview(time);
});
useEffect(() => {
if (isPreviewDisabled || !url || !isReady) return undefined;
return createVideoPreviews(url, previewCanvasRef.current!);
}, [url, isReady, isPreviewDisabled]);
useEffect(() => {
setPreviewVisible(false);
}, [isActive]);
useEffect(() => {
if (cancelAnimation) cancelAnimation();
cancelAnimation = undefined;
if (!isLockedRef.current && !isSeeking) {
const time = getCurrentTime();
const remaining = duration - time;
cancelAnimation = animateNumber({
from: time,
to: duration,
duration: (remaining * 1000) / playbackRate,
onUpdate: setSelectedTime,
});
}
}, [getCurrentTime, isSeeking, setSelectedTime, playbackRate, duration]);
useEffect(() => {
if (!isPlaying || getIsWaiting()) {
if (cancelAnimation) cancelAnimation();
cancelAnimation = undefined;
}
}, [isPlaying, getSelectedTime, getIsWaiting]);
useEffect(() => {
if (isPlaying) {
if (cancelAnimation) cancelAnimation();
cancelAnimation = undefined;
const time = getCurrentTime();
const remaining = duration - time;
cancelAnimation = animateNumber({
from: time,
to: duration,
duration: (remaining * 1000) / playbackRate,
onUpdate: setSelectedTime,
});
}
// eslint-disable-next-line
}, [isPlaying, playbackRate, duration]);
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 stopAnimation = () => {
if (cancelAnimation) cancelAnimation();
cancelAnimation = undefined;
};
const handleSeek = (e: MouseEvent | TouchEvent) => {
stopAnimation();
setPreviewVisible(true);
([time, offset] = getPreviewProps(e));
void setPreview(time);
setPreviewOffset(offset);
setSelectedTime(time);
};
const handleStartSeek = () => {
stopAnimation();
setPreviewVisible(true);
setIsSeeking(true);
onSeekStart();
};
const handleStopSeek = () => {
stopAnimation();
isLockedRef.current = true;
setPreviewVisible(false);
setIsSeeking(false);
setSelectedTime(time);
onSeek(time);
// Prevent current time updates from overriding the selected time
setTimeout(() => {
isLockedRef.current = false;
}, LOCK_TIMEOUT);
};
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,
playbackRate,
]);
return (
<div className={styles.container} ref={seekerRef}>
{!isPreviewDisabled && (
<ShowTransition
isOpen
isHidden={!isPreviewVisible}
className={styles.preview}
style={`width: ${previewSize.width}px; height: ${previewSize.height}px`}
ref={previewRef}
>
<canvas className={styles.previewCanvas} ref={previewCanvasRef} />
<div className={styles.previewTime}>
<span className={styles.previewTimeText} ref={previewTimeRef} />
</div>
</ShowTransition>
)}
<div className={styles.track}>
{bufferedRanges.map(({
start,
end,
}) => (
<div
key={`${start}-${end}`}
className={styles.buffered}
// @ts-ignore
style={`left: ${start * 100}%; right: ${100 - end * 100}%`}
/>
))}
</div>
<div className={styles.track}>
<div
ref={progressRef}
className={buildClassName(styles.played, isSeeking && styles.seeking)}
/>
</div>
</div>
);
};
export default memo(SeekLine);