Fix mobile timeline and add more icons for detections

This commit is contained in:
Nick Mowen 2023-12-30 08:03:34 -07:00
parent efb98f725f
commit d6479e2dc1
6 changed files with 149 additions and 88 deletions

View File

@ -10,11 +10,12 @@ import {
getTimelineIcon,
getTimelineItemDescription,
} from "@/utils/timelineUtil";
import { Button } from "../ui/button";
type HistoryCardProps = {
timeline: Card;
relevantPreview?: Preview;
shouldAutoPlay: boolean;
isMobile: boolean;
onClick?: () => void;
onDelete?: () => void;
};
@ -22,7 +23,7 @@ type HistoryCardProps = {
export default function HistoryCard({
relevantPreview,
timeline,
shouldAutoPlay,
isMobile,
onClick,
onDelete,
}: HistoryCardProps) {
@ -42,11 +43,12 @@ export default function HistoryCard({
relevantPreview={relevantPreview}
startTs={Object.values(timeline.entries)[0].timestamp}
eventId={Object.values(timeline.entries)[0].source_id}
shouldAutoPlay={shouldAutoPlay}
isMobile={isMobile}
onClick={onClick}
/>
<div className="p-2">
<>
<div className="text-sm flex justify-between items-center">
<div>
<div className="pl-1 pt-1">
<LuClock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(timeline.time, {
strftime_fmt:
@ -55,35 +57,38 @@ export default function HistoryCard({
date_style: "medium",
})}
</div>
<LuTrash
className="w-5 h-5 m-1 cursor-pointer"
stroke="#f87171"
onClick={(e: Event) => {
e.stopPropagation();
<Button className="px-2 py-2" variant="ghost" size="xs">
<LuTrash
className="w-5 h-5 stroke-red-500"
onClick={(e: Event) => {
e.stopPropagation();
if (onDelete) {
onDelete();
}
}}
/>
if (onDelete) {
onDelete();
}
}}
/>
</Button>
</div>
<div className="capitalize text-sm flex items-center mt-1">
<div className="pl-1 capitalize text-sm flex items-center mt-1">
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
{timeline.camera.replaceAll("_", " ")}
</div>
<div className="my-2 text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry], idx) => {
return (
<div
key={idx}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
<div className="pl-1 my-2">
<div className="text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry], idx) => {
return (
<div
key={idx}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
</>
</Card>
);
}

View File

@ -1,7 +1,13 @@
import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer";
import useSWR from "swr";
import { useCallback, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
@ -12,7 +18,8 @@ type PreviewPlayerProps = {
relevantPreview?: Preview;
startTs: number;
eventId: string;
shouldAutoPlay: boolean;
isMobile: boolean;
onClick?: () => void;
};
type Preview = {
@ -28,11 +35,11 @@ export default function PreviewThumbnailPlayer({
relevantPreview,
startTs,
eventId,
shouldAutoPlay,
isMobile,
onClick,
}: PreviewPlayerProps) {
const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const isSafari = useMemo(() => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}, []);
@ -78,7 +85,7 @@ export default function PreviewThumbnailPlayer({
}
}
if (shouldAutoPlay && !autoPlayObserver.current) {
if (isMobile && !autoPlayObserver.current) {
try {
autoPlayObserver.current = new IntersectionObserver(
(entries) => {
@ -103,20 +110,92 @@ export default function PreviewThumbnailPlayer({
[preloadObserver, autoPlayObserver, onPlayback]
);
let content;
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
<PreviewContent
playerRef={playerRef}
relevantPreview={relevantPreview}
visible={visible}
startTs={startTs}
camera={camera}
config={config}
eventId={eventId}
isMobile={isMobile}
isSafari={isSafari}
onClick={onClick}
/>
</AspectRatio>
);
}
type PreviewContentProps = {
playerRef: React.MutableRefObject<Player | null>;
config: FrigateConfig;
camera: string;
relevantPreview: Preview | undefined;
eventId: string;
visible: boolean;
startTs: number;
isMobile: boolean;
isSafari: boolean;
onClick?: () => void;
};
function PreviewContent({
playerRef,
config,
camera,
relevantPreview,
eventId,
visible,
startTs,
isMobile,
isSafari,
onClick,
}: PreviewContentProps) {
const apiHost = useApiHost();
// handle touchstart -> touchend as click
const [touchStart, setTouchStart] = useState(0);
const handleTouchStart = useCallback(() => {
setTouchStart(new Date().getTime());
}, []);
useEffect(() => {
if (!isMobile || !playerRef.current || !onClick) {
return;
}
playerRef.current.on("touchend", () => {
if (!onClick) {
return;
}
const touchEnd = new Date().getTime();
// consider tap less than 500 ms
if (touchEnd - touchStart < 500) {
onClick();
}
});
}, [playerRef, touchStart]);
if (relevantPreview && !visible) {
content = <div />;
return <div />;
} else if (!relevantPreview) {
if (isCurrentHour(startTs)) {
content = (
return (
<img
className={`${getPreviewWidth(camera, config)}`}
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/>
);
} else {
content = (
return (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
@ -124,7 +203,7 @@ export default function PreviewThumbnailPlayer({
);
}
} else {
content = (
return (
<>
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer
@ -147,6 +226,9 @@ export default function PreviewThumbnailPlayer({
player.pause(); // autoplay + pause is required for iOS
player.playbackRate(isSafari ? 2 : 8);
player.currentTime(startTs - relevantPreview.start);
if (isMobile && onClick) {
player.on("touchstart", handleTouchStart);
}
}}
onDispose={() => {
playerRef.current = null;
@ -157,18 +239,6 @@ export default function PreviewThumbnailPlayer({
</>
);
}
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
{content}
</AspectRatio>
);
}
function isCurrentHour(timestamp: number) {

View File

@ -129,11 +129,16 @@ function ActivityScrubber({
return;
}
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
const timelineInstance = new VisTimeline(
divElement,
items as DataItem[],
groups as DataGroup[],
options
timelineOptions
);
if (timeBars) {
@ -151,41 +156,11 @@ function ActivityScrubber({
timelineRef.current.timeline = timelineInstance;
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
timelineInstance.setOptions(timelineOptions);
return () => {
timelineInstance.destroy();
};
}, [containerRef]);
useEffect(() => {
if (!timelineRef.current.timeline) {
return;
}
// If the currentTime updates, adjust the scrubber's end date and max
// May not be applicable to all scrubbers, might want to just pass this in
// for any scrubbers that we want to dynamically move based on time
// const updatedTimeOptions: TimelineOptions = {
// end: currentTime,
// max: currentTime,
// };
const timelineOptions: TimelineOptions = {
...defaultOptions,
// ...updatedTimeOptions,
...options,
};
timelineRef.current.timeline.setOptions(timelineOptions);
if (items) timelineRef.current.timeline.setItems(items);
}, [items, groups, options, currentTime, eventHandlers]);
return (
<div className={className || ""}>
<div ref={containerRef} />

View File

@ -1,21 +1,25 @@
import {
LuCamera,
LuCar,
LuCat,
LuCircle,
LuCircleDot,
LuDog,
LuEar,
LuFocus,
LuPackage,
LuPersonStanding,
LuPlay,
LuPlayCircle,
LuTruck,
} from "react-icons/lu";
import { GiDeer } from "react-icons/gi";
import { IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { FaBicycle } from "react-icons/fa";
export function getTimelineIcon(timelineItem: Timeline) {
switch (timelineItem.class_type) {
@ -61,14 +65,22 @@ export function getTimelineIcon(timelineItem: Timeline) {
*/
export function getTimelineDetectionIcon(timelineItem: Timeline) {
switch (timelineItem.data.label) {
case "person":
return <LuPersonStanding className="w-4 mr-1" />;
case "bicycle":
return <FaBicycle className="w-4 mr-1" />;
case "car":
return <LuCar className="w-4 mr-1" />;
case "cat":
return <LuCat className="w-4 mr-1" />;
case "deer":
return <GiDeer className="w-4 mr-1" />;
case "dog":
return <LuDog className="w-4 mr-1" />;
case "package":
return <LuPackage className="w-4 mr-1" />;
case "person":
return <LuPersonStanding className="w-4 mr-1" />;
default:
return <LuFocus className="w-4 mr-1" />;
return <LuCamera className="w-4 mr-1" />;
}
}

View File

@ -112,7 +112,7 @@ export default function HistoryCardView({
<HistoryCard
key={key}
timeline={timeline}
shouldAutoPlay={isMobile}
isMobile={isMobile}
relevantPreview={relevantPreview}
onClick={() => {
onItemSelected({

View File

@ -110,7 +110,6 @@ export default function HistoryTimelineView({
playerRef.current?.pause();
let seekSeconds = 0;
console.log("recordings are " + recordings?.length);
(recordings || []).every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > selected) {