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

View File

@ -1,7 +1,13 @@
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer"; import VideoPlayer from "./VideoPlayer";
import useSWR from "swr"; 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 { useApiHost } from "@/api";
import Player from "video.js/dist/types/player"; import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio"; import { AspectRatio } from "../ui/aspect-ratio";
@ -12,7 +18,8 @@ type PreviewPlayerProps = {
relevantPreview?: Preview; relevantPreview?: Preview;
startTs: number; startTs: number;
eventId: string; eventId: string;
shouldAutoPlay: boolean; isMobile: boolean;
onClick?: () => void;
}; };
type Preview = { type Preview = {
@ -28,11 +35,11 @@ export default function PreviewThumbnailPlayer({
relevantPreview, relevantPreview,
startTs, startTs,
eventId, eventId,
shouldAutoPlay, isMobile,
onClick,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null); const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const isSafari = useMemo(() => { const isSafari = useMemo(() => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 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 { try {
autoPlayObserver.current = new IntersectionObserver( autoPlayObserver.current = new IntersectionObserver(
(entries) => { (entries) => {
@ -103,20 +110,92 @@ export default function PreviewThumbnailPlayer({
[preloadObserver, autoPlayObserver, onPlayback] [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) { if (relevantPreview && !visible) {
content = <div />; return <div />;
} else if (!relevantPreview) { } else if (!relevantPreview) {
if (isCurrentHour(startTs)) { if (isCurrentHour(startTs)) {
content = ( return (
<img <img
className={`${getPreviewWidth(camera, config)}`} className={`${getPreviewWidth(camera, config)}`}
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/> />
); );
} else { } else {
content = ( return (
<img <img
className="w-[160px]" className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`} src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
@ -124,7 +203,7 @@ export default function PreviewThumbnailPlayer({
); );
} }
} else { } else {
content = ( return (
<> <>
<div className={`${getPreviewWidth(camera, config)}`}> <div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer <VideoPlayer
@ -147,6 +226,9 @@ export default function PreviewThumbnailPlayer({
player.pause(); // autoplay + pause is required for iOS player.pause(); // autoplay + pause is required for iOS
player.playbackRate(isSafari ? 2 : 8); player.playbackRate(isSafari ? 2 : 8);
player.currentTime(startTs - relevantPreview.start); player.currentTime(startTs - relevantPreview.start);
if (isMobile && onClick) {
player.on("touchstart", handleTouchStart);
}
}} }}
onDispose={() => { onDispose={() => {
playerRef.current = null; 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) { function isCurrentHour(timestamp: number) {

View File

@ -129,11 +129,16 @@ function ActivityScrubber({
return; return;
} }
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
const timelineInstance = new VisTimeline( const timelineInstance = new VisTimeline(
divElement, divElement,
items as DataItem[], items as DataItem[],
groups as DataGroup[], groups as DataGroup[],
options timelineOptions
); );
if (timeBars) { if (timeBars) {
@ -151,41 +156,11 @@ function ActivityScrubber({
timelineRef.current.timeline = timelineInstance; timelineRef.current.timeline = timelineInstance;
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
timelineInstance.setOptions(timelineOptions);
return () => { return () => {
timelineInstance.destroy(); timelineInstance.destroy();
}; };
}, [containerRef]); }, [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 ( return (
<div className={className || ""}> <div className={className || ""}>
<div ref={containerRef} /> <div ref={containerRef} />

View File

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

View File

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