Show progress bar on preview thumbnail

This commit is contained in:
Nicolas Mowen 2024-02-17 23:50:10 -07:00
parent b2485d147c
commit c0d2d6ba57
3 changed files with 63 additions and 36 deletions

View File

@ -4,12 +4,12 @@ import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player"; import Player from "video.js/dist/types/player";
import { isCurrentHour } from "@/utils/dateUtil"; import { isCurrentHour } from "@/utils/dateUtil";
import { isSafari } from "@/utils/browserUtil"; import { isSafari } from "@/utils/browserUtil";
import { ReviewSegment } from "@/types/review";
import { Slider } from "../ui/slider";
type PreviewPlayerProps = { type PreviewPlayerProps = {
camera: string; review: ReviewSegment;
relevantPreview?: Preview; relevantPreview?: Preview;
startTs: number;
eventId: string;
isMobile: boolean; isMobile: boolean;
onClick?: () => void; onClick?: () => void;
}; };
@ -23,17 +23,17 @@ type Preview = {
}; };
export default function PreviewThumbnailPlayer({ export default function PreviewThumbnailPlayer({
camera, review,
relevantPreview, relevantPreview,
startTs,
eventId,
isMobile, isMobile,
onClick, onClick,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [hover, setHover] = useState(false);
const [isInitiallyVisible, setIsInitiallyVisible] = useState(false); const [isInitiallyVisible, setIsInitiallyVisible] = useState(false);
const [progress, setProgress] = useState(0);
const onPlayback = useCallback( const onPlayback = useCallback(
(isHovered: Boolean) => { (isHovered: Boolean) => {
@ -50,13 +50,18 @@ export default function PreviewThumbnailPlayer({
} }
if (isHovered) { if (isHovered) {
setHover(true);
playerRef.current.play(); playerRef.current.play();
} else { } else {
setHover(false);
setProgress(0);
playerRef.current.pause(); playerRef.current.pause();
playerRef.current.currentTime(startTs - relevantPreview.start); playerRef.current.currentTime(
review.start_time - relevantPreview.start
);
} }
}, },
[relevantPreview, startTs, playerRef] [relevantPreview, review, playerRef]
); );
const autoPlayObserver = useRef<IntersectionObserver | null>(); const autoPlayObserver = useRef<IntersectionObserver | null>();
@ -111,47 +116,53 @@ export default function PreviewThumbnailPlayer({
return ( return (
<div <div
ref={relevantPreview ? inViewRef : null} ref={relevantPreview ? inViewRef : null}
className="relative w-full h-full" className="relative w-full h-full cursor-pointer"
onMouseEnter={() => onPlayback(true)} onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)} onMouseLeave={() => onPlayback(false)}
> >
<PreviewContent <PreviewContent
playerRef={playerRef} playerRef={playerRef}
review={review}
relevantPreview={relevantPreview} relevantPreview={relevantPreview}
isVisible={visible} isVisible={visible}
isInitiallyVisible={isInitiallyVisible} isInitiallyVisible={isInitiallyVisible}
startTs={startTs}
camera={camera}
eventId={eventId}
isMobile={isMobile} isMobile={isMobile}
setProgress={setProgress}
onClick={onClick} onClick={onClick}
/> />
<div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" /> <div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none" /> <div className="absolute bottom-0 left-0 right-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none" />
{hover && (
<Slider
className="absolute left-0 right-0 bottom-0 z-10"
value={[progress]}
min={0}
step={1}
max={100}
/>
)}
</div> </div>
); );
} }
type PreviewContentProps = { type PreviewContentProps = {
playerRef: React.MutableRefObject<Player | null>; playerRef: React.MutableRefObject<Player | null>;
camera: string; review: ReviewSegment;
relevantPreview: Preview | undefined; relevantPreview: Preview | undefined;
eventId: string;
isVisible: boolean; isVisible: boolean;
isInitiallyVisible: boolean; isInitiallyVisible: boolean;
startTs: number;
isMobile: boolean; isMobile: boolean;
setProgress?: (progress: number) => void;
onClick?: () => void; onClick?: () => void;
}; };
function PreviewContent({ function PreviewContent({
playerRef, playerRef,
camera, review,
relevantPreview, relevantPreview,
eventId,
isVisible, isVisible,
isInitiallyVisible, isInitiallyVisible,
startTs,
isMobile, isMobile,
setProgress,
onClick, onClick,
}: PreviewContentProps) { }: PreviewContentProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -183,18 +194,18 @@ function PreviewContent({
if (relevantPreview && !isVisible) { if (relevantPreview && !isVisible) {
return <div />; return <div />;
} else if (!relevantPreview && isCurrentHour(startTs)) { } else if (!relevantPreview && isCurrentHour(review.start_time)) {
return ( return (
<img <img
className="w-full" className="w-full"
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} src={`${apiHost}api/preview/${review.camera}/${review.start_time}/thumbnail.jpg`}
/> />
); );
} else if (!relevantPreview && !isCurrentHour(startTs)) { } else if (!relevantPreview && !isCurrentHour(review.start_time)) {
return ( return (
<img <img
className="w-[160px]" className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`} src={`${apiHost}api/events/${review.id}/thumbnail.jpg`}
/> />
); );
} else { } else {
@ -224,12 +235,31 @@ function PreviewContent({
return; return;
} }
const playerStartTime = review.start_time - relevantPreview.start;
if (!isInitiallyVisible) { if (!isInitiallyVisible) {
player.pause(); // autoplay + pause is required for iOS player.pause(); // autoplay + pause is required for iOS
} }
player.playbackRate(slowPlayack ? 2 : 8); player.playbackRate(slowPlayack ? 2 : 8);
player.currentTime(startTs - relevantPreview.start); player.currentTime(playerStartTime);
player.on("timeupdate", () => {
if (!setProgress || playerRef.current?.paused()) {
return;
}
const playerProgress =
(player.currentTime() || 0) - playerStartTime;
const playerDuration = review.end_time - review.start_time;
const playerPercent = (playerProgress / playerDuration) * 100;
if (playerPercent > 100) {
playerRef.current?.pause();
setProgress(100.0);
} else {
setProgress(playerPercent);
}
});
if (isMobile && onClick) { if (isMobile && onClick) {
player.on("touchstart", handleTouchStart); player.on("touchstart", handleTouchStart);
} }

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@ -15,12 +15,11 @@ const Slider = React.forwardRef<
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-blue-500" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider } export { Slider };

View File

@ -186,17 +186,15 @@ export default function Events() {
<div key={value.id}> <div key={value.id}>
<div <div
ref={lastRow ? lastReviewRef : null} ref={lastRow ? lastReviewRef : null}
className="relative h-[234px] rounded-2xl overflow-hidden" className="relative h-[234px] rounded-lg overflow-hidden"
style={{ style={{
aspectRatio: detectConfig.width / detectConfig.height, aspectRatio: detectConfig.width / detectConfig.height,
}} }}
> >
<PreviewThumbnailPlayer <PreviewThumbnailPlayer
review={value}
relevantPreview={relevantPreview} relevantPreview={relevantPreview}
camera={value.camera}
startTs={value.start_time}
isMobile={false} isMobile={false}
eventId=""
/> />
{(severity == "alert" || severity == "detection") && ( {(severity == "alert" || severity == "detection") && (
<div className="absolute top-1 right-1 flex"> <div className="absolute top-1 right-1 flex">