mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 11:45:24 +03:00
Fix mobile timeline and add more icons for detections
This commit is contained in:
parent
efb98f725f
commit
d6479e2dc1
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ export default function HistoryCardView({
|
||||
<HistoryCard
|
||||
key={key}
|
||||
timeline={timeline}
|
||||
shouldAutoPlay={isMobile}
|
||||
isMobile={isMobile}
|
||||
relevantPreview={relevantPreview}
|
||||
onClick={() => {
|
||||
onItemSelected({
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user