mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Review segment UI (#9945)
* Add ui for events * Display data for review items * Use preview thumbnails * Implement paging * Show icons for what was detected * Show progress bar on preview thumbnail * Hide the overlays on hover and update reviewed status * Dim previews that have been reviewed * Show audio icons * Cleanup preview thumb player * initial implementation of review timeline * Use timeout for hover playback * Break apart mobile and desktop views * Show icons for sub labels * autoplay first video on mobile * Only show the last 24 hours by default * Rework scrolling to fix nested scrolling * Final scroll cleanups --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
parent
be4b570346
commit
509e46adc8
@ -2420,6 +2420,40 @@ def review():
|
||||
return jsonify([r for r in review])
|
||||
|
||||
|
||||
@bp.route("/review/<id>/viewed", methods=("POST",))
|
||||
def set_reviewed(id):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
||||
except DoesNotExist:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
|
||||
)
|
||||
|
||||
review.has_been_reviewed = True
|
||||
review.save()
|
||||
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/review/<id>/viewed", methods=("DELETE",))
|
||||
def set_not_reviewed(id):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
|
||||
except DoesNotExist:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
|
||||
)
|
||||
|
||||
review.has_been_reviewed = False
|
||||
review.save()
|
||||
|
||||
return make_response(
|
||||
jsonify({"success": True, "message": "Reviewed " + id + " not viewed"}), 200
|
||||
)
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
|
||||
)
|
||||
|
91
web/package-lock.json
generated
91
web/package-lock.json
generated
@ -23,6 +23,8 @@
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"apexcharts": "^3.45.1",
|
||||
"axios": "^1.6.2",
|
||||
@ -38,6 +40,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-icons": "^4.12.0",
|
||||
@ -1800,6 +1803,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.0.3.tgz",
|
||||
"integrity": "sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/primitive": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz",
|
||||
"integrity": "sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/primitive": "1.0.1",
|
||||
"@radix-ui/react-context": "1.0.1",
|
||||
"@radix-ui/react-direction": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.3",
|
||||
"@radix-ui/react-roving-focus": "1.0.4",
|
||||
"@radix-ui/react-toggle": "1.0.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz",
|
||||
@ -6748,6 +6805,18 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-device-detect": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz",
|
||||
"integrity": "sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==",
|
||||
"dependencies": {
|
||||
"ua-parser-js": "^1.0.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0",
|
||||
"react-dom": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
@ -8062,6 +8131,28 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.37",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
|
||||
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",
|
||||
|
@ -28,6 +28,8 @@
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"apexcharts": "^3.45.1",
|
||||
"axios": "^1.6.2",
|
||||
@ -43,6 +45,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-icons": "^4.12.0",
|
||||
|
@ -14,6 +14,7 @@ import Logs from "@/pages/Logs";
|
||||
import NoMatch from "@/pages/NoMatch";
|
||||
import Settings from "@/pages/Settings";
|
||||
import UIPlayground from "./pages/UIPlayground";
|
||||
import Events from "./pages/Events";
|
||||
|
||||
function App() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
@ -27,14 +28,15 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Wrapper>
|
||||
<Header onToggleNavbar={toggleNavbar} />
|
||||
<div className="grid grid-cols-[auto,1fr] flex-grow-1 overflow-auto">
|
||||
<div className="w-full h-full pt-2 overflow-hidden">
|
||||
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
|
||||
<div
|
||||
id="pageRoot"
|
||||
className="overflow-x-hidden px-4 py-2 w-screen md:w-full"
|
||||
className="absolute left-0 md:left-16 top-16 md:top-2 right-0 bottom-0 overflow-hidden"
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Live />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/export" element={<Export />} />
|
||||
<Route path="/storage" element={<Storage />} />
|
||||
|
@ -5,7 +5,7 @@ type TWrapperProps = {
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: TWrapperProps) => {
|
||||
return <main className="flex flex-col h-screen">{children}</main>;
|
||||
return <main className="w-screen h-screen overflow-hidden">{children}</main>;
|
||||
};
|
||||
|
||||
export default Wrapper;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import useSWR from "swr";
|
||||
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
||||
import { Card } from "../ui/card";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import ActivityIndicator from "../ui/activity-indicator";
|
||||
@ -21,8 +20,10 @@ type HistoryCardProps = {
|
||||
};
|
||||
|
||||
export default function HistoryCard({
|
||||
// @ts-ignore
|
||||
relevantPreview,
|
||||
timeline,
|
||||
// @ts-ignore
|
||||
isMobile,
|
||||
onClick,
|
||||
onDelete,
|
||||
@ -38,14 +39,6 @@ export default function HistoryCard({
|
||||
className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<PreviewThumbnailPlayer
|
||||
camera={timeline.camera}
|
||||
relevantPreview={relevantPreview}
|
||||
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||
eventId={Object.values(timeline.entries)[0].source_id}
|
||||
isMobile={isMobile}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<>
|
||||
<div className="text-sm flex justify-between items-center">
|
||||
<div className="pl-1 pt-1">
|
||||
|
@ -2,23 +2,26 @@ import VideoPlayer from "./VideoPlayer";
|
||||
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";
|
||||
import { LuPlayCircle } from "react-icons/lu";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { isSafari } from "@/utils/browserUtil";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isMobile, isSafari } from "react-device-detect";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
camera: string;
|
||||
review: ReviewSegment;
|
||||
relevantPreview?: Preview;
|
||||
startTs: number;
|
||||
eventId: string;
|
||||
isMobile: boolean;
|
||||
onClick?: () => void;
|
||||
autoPlayback?: boolean;
|
||||
setReviewed?: () => void;
|
||||
};
|
||||
|
||||
type Preview = {
|
||||
@ -30,17 +33,43 @@ type Preview = {
|
||||
};
|
||||
|
||||
export default function PreviewThumbnailPlayer({
|
||||
camera,
|
||||
review,
|
||||
relevantPreview,
|
||||
startTs,
|
||||
eventId,
|
||||
isMobile,
|
||||
onClick,
|
||||
autoPlayback = false,
|
||||
setReviewed,
|
||||
}: PreviewPlayerProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const playerRef = useRef<Player | null>(null);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isInitiallyVisible, setIsInitiallyVisible] = useState(false);
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
||||
const [playback, setPlayback] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const playingBack = useMemo(
|
||||
() => relevantPreview && playback,
|
||||
[playback, autoPlayback, relevantPreview]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlayback) {
|
||||
setPlayback(false);
|
||||
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setPlayback(true);
|
||||
setHoverTimeout(null);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [autoPlayback]);
|
||||
|
||||
const onPlayback = useCallback(
|
||||
(isHovered: Boolean) => {
|
||||
@ -48,205 +77,181 @@ export default function PreviewThumbnailPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerRef.current) {
|
||||
if (isHovered) {
|
||||
setIsInitiallyVisible(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
playerRef.current.play();
|
||||
setHoverTimeout(
|
||||
setTimeout(() => {
|
||||
setPlayback(true);
|
||||
setHoverTimeout(null);
|
||||
}, 500)
|
||||
);
|
||||
} else {
|
||||
playerRef.current.pause();
|
||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||
}
|
||||
},
|
||||
[relevantPreview, startTs, playerRef]
|
||||
);
|
||||
|
||||
const autoPlayObserver = useRef<IntersectionObserver | null>();
|
||||
const preloadObserver = useRef<IntersectionObserver | null>();
|
||||
const inViewRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!preloadObserver.current) {
|
||||
try {
|
||||
preloadObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [{ isIntersecting }] = entries;
|
||||
setVisible(isIntersecting);
|
||||
},
|
||||
{
|
||||
threshold: 0,
|
||||
root: document.getElementById("pageRoot"),
|
||||
rootMargin: "10% 0px 25% 0px",
|
||||
}
|
||||
);
|
||||
if (node) preloadObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile && !autoPlayObserver.current) {
|
||||
try {
|
||||
autoPlayObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [{ isIntersecting }] = entries;
|
||||
if (isIntersecting) {
|
||||
onPlayback(true);
|
||||
} else {
|
||||
onPlayback(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 1.0,
|
||||
root: document.getElementById("pageRoot"),
|
||||
rootMargin: "-10% 0px -25% 0px",
|
||||
}
|
||||
setPlayback(false);
|
||||
setProgress(0);
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.pause();
|
||||
playerRef.current.currentTime(
|
||||
review.start_time - relevantPreview.start
|
||||
);
|
||||
if (node) autoPlayObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
},
|
||||
[preloadObserver, autoPlayObserver, onPlayback]
|
||||
[hoverTimeout, relevantPreview, review, playerRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<AspectRatio
|
||||
ref={relevantPreview ? inViewRef : null}
|
||||
ratio={16 / 9}
|
||||
className="bg-black flex justify-center items-center"
|
||||
onMouseEnter={() => onPlayback(true)}
|
||||
onMouseLeave={() => onPlayback(false)}
|
||||
<div
|
||||
className="relative w-full h-full cursor-pointer"
|
||||
onMouseEnter={isMobile ? undefined : () => onPlayback(true)}
|
||||
onMouseLeave={isMobile ? undefined : () => onPlayback(false)}
|
||||
>
|
||||
<PreviewContent
|
||||
playerRef={playerRef}
|
||||
relevantPreview={relevantPreview}
|
||||
isVisible={visible}
|
||||
isInitiallyVisible={isInitiallyVisible}
|
||||
startTs={startTs}
|
||||
camera={camera}
|
||||
eventId={eventId}
|
||||
isMobile={isMobile}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</AspectRatio>
|
||||
{playingBack ? (
|
||||
<PreviewContent
|
||||
playerRef={playerRef}
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
playback={playingBack}
|
||||
setProgress={setProgress}
|
||||
setReviewed={setReviewed}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="h-full w-full"
|
||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||
/>
|
||||
)}
|
||||
{!playingBack &&
|
||||
(review.severity == "alert" || review.severity == "detection") && (
|
||||
<div className="absolute top-1 left-[6px] flex gap-1">
|
||||
{review.data.objects.map((object) => {
|
||||
return getIconForLabel(object, "w-3 h-3 text-white");
|
||||
})}
|
||||
{review.data.audio.map((audio) => {
|
||||
return getIconForLabel(audio, "w-3 h-3 text-white");
|
||||
})}
|
||||
{review.data.sub_labels?.map((sub) => {
|
||||
return getIconForSubLabel(sub, "w-3 h-3 text-white");
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!playingBack && (
|
||||
<div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white">
|
||||
<TimeAgo time={review.start_time * 1000} />
|
||||
{config &&
|
||||
formatUnixTimestampToDateTime(review.start_time, {
|
||||
strftime_fmt:
|
||||
config.ui.time_format == "24hour"
|
||||
? "%b %-d, %H:%M"
|
||||
: "%b %-d, %I:%M %p",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<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" />
|
||||
{playingBack && (
|
||||
<Slider
|
||||
className="absolute left-0 right-0 bottom-0 z-10"
|
||||
value={[progress]}
|
||||
min={0}
|
||||
step={1}
|
||||
max={100}
|
||||
/>
|
||||
)}
|
||||
{!playingBack && review.has_been_reviewed && (
|
||||
<div className="absolute left-0 top-0 bottom-0 right-0 bg-black bg-opacity-60" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PreviewContentProps = {
|
||||
playerRef: React.MutableRefObject<Player | null>;
|
||||
camera: string;
|
||||
review: ReviewSegment;
|
||||
relevantPreview: Preview | undefined;
|
||||
eventId: string;
|
||||
isVisible: boolean;
|
||||
isInitiallyVisible: boolean;
|
||||
startTs: number;
|
||||
isMobile: boolean;
|
||||
onClick?: () => void;
|
||||
playback: boolean;
|
||||
setProgress?: (progress: number) => void;
|
||||
setReviewed?: () => void;
|
||||
};
|
||||
function PreviewContent({
|
||||
playerRef,
|
||||
camera,
|
||||
review,
|
||||
relevantPreview,
|
||||
eventId,
|
||||
isVisible,
|
||||
isInitiallyVisible,
|
||||
startTs,
|
||||
isMobile,
|
||||
onClick,
|
||||
playback,
|
||||
setProgress,
|
||||
setReviewed,
|
||||
}: PreviewContentProps) {
|
||||
const apiHost = useApiHost();
|
||||
const slowPlayack = isSafari();
|
||||
|
||||
// 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 100 ms
|
||||
if (touchEnd - touchStart < 100) {
|
||||
onClick();
|
||||
}
|
||||
});
|
||||
}, [playerRef, touchStart]);
|
||||
|
||||
if (relevantPreview && !isVisible) {
|
||||
return <div />;
|
||||
} else if (!relevantPreview && !isCurrentHour(startTs)) {
|
||||
if (relevantPreview && playback) {
|
||||
return (
|
||||
<img
|
||||
className="w-[160px]"
|
||||
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: "auto",
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
muted: true,
|
||||
fluid: true,
|
||||
aspectRatio: "16:9",
|
||||
loadingSpinner: false,
|
||||
sources: relevantPreview
|
||||
? [
|
||||
{
|
||||
src: `${relevantPreview.src}`,
|
||||
type: "video/mp4",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
|
||||
if (!relevantPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
// start with a bit of padding
|
||||
const playerStartTime = Math.max(
|
||||
0,
|
||||
review.start_time - relevantPreview.start - 8
|
||||
);
|
||||
|
||||
player.playbackRate(isSafari ? 2 : 8);
|
||||
player.currentTime(playerStartTime);
|
||||
player.on("timeupdate", () => {
|
||||
if (!setProgress || playerRef.current?.paused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerProgress =
|
||||
(player.currentTime() || 0) - playerStartTime;
|
||||
|
||||
// end with a bit of padding
|
||||
const playerDuration = review.end_time - review.start_time + 8;
|
||||
const playerPercent = (playerProgress / playerDuration) * 100;
|
||||
|
||||
if (
|
||||
setReviewed &&
|
||||
!review.has_been_reviewed &&
|
||||
playerPercent > 50
|
||||
) {
|
||||
setReviewed();
|
||||
}
|
||||
|
||||
if (playerPercent > 100) {
|
||||
playerRef.current?.pause();
|
||||
setProgress(100.0);
|
||||
} else {
|
||||
setProgress(playerPercent);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = null;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: "auto",
|
||||
aspectRatio: "16:9",
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loadingSpinner: false,
|
||||
poster: relevantPreview
|
||||
? ""
|
||||
: `${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`,
|
||||
sources: relevantPreview
|
||||
? [
|
||||
{
|
||||
src: `${relevantPreview.src}`,
|
||||
type: "video/mp4",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
|
||||
if (!relevantPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInitiallyVisible) {
|
||||
player.pause(); // autoplay + pause is required for iOS
|
||||
}
|
||||
|
||||
player.playbackRate(slowPlayack ? 2 : 8);
|
||||
player.currentTime(startTs - relevantPreview.start);
|
||||
if (isMobile && onClick) {
|
||||
player.on("touchstart", handleTouchStart);
|
||||
}
|
||||
}}
|
||||
onDispose={() => {
|
||||
playerRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{relevantPreview && (
|
||||
<LuPlayCircle className="absolute z-10 left-1 bottom-1 w-4 h-4 text-white text-opacity-60" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export function EventReviewTimeline({
|
||||
const currentTimeRef = useRef<HTMLDivElement>(null);
|
||||
const observer = useRef<ResizeObserver | null>(null);
|
||||
const timelineDuration = useMemo(
|
||||
() => timelineEnd - timelineStart,
|
||||
() => timelineStart - timelineEnd,
|
||||
[timelineEnd, timelineStart]
|
||||
);
|
||||
|
||||
@ -208,7 +208,7 @@ export function EventReviewTimeline({
|
||||
return (
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={`relative w-[120px] md:w-[100px] h-[100dvh] overflow-y-scroll no-scrollbar bg-secondary ${
|
||||
className={`relative w-[120px] md:w-[100px] h-full overflow-y-scroll no-scrollbar bg-secondary ${
|
||||
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto"
|
||||
}`}
|
||||
>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
@ -15,12 +15,11 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
|
||||
</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>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider }
|
||||
export { Slider };
|
||||
|
59
web/src/components/ui/toggle-group.tsx
Normal file
59
web/src/components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
43
web/src/components/ui/toggle.tsx
Normal file
43
web/src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
@ -164,4 +164,4 @@ export const useSegmentUtils = (
|
||||
getReviewed,
|
||||
shouldShowRoundedCorners,
|
||||
};
|
||||
};
|
||||
};
|
11
web/src/pages/Events.tsx
Normal file
11
web/src/pages/Events.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import DesktopEventView from "@/views/events/DesktopEventView";
|
||||
import MobileEventView from "@/views/events/MobileEventView";
|
||||
import { isMobile } from 'react-device-detect';
|
||||
|
||||
export default function Events() {
|
||||
if (isMobile) {
|
||||
return <MobileEventView />;
|
||||
}
|
||||
|
||||
return <DesktopEventView />;
|
||||
}
|
@ -5,8 +5,8 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Event as FrigateEvent } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isSafari } from "@/utils/browserUtil";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
|
||||
function Live() {
|
||||
@ -65,7 +65,6 @@ function Live() {
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
const safari = isSafari();
|
||||
const [windowVisible, setWindowVisible] = useState(true);
|
||||
const visibilityListener = useCallback(() => {
|
||||
setWindowVisible(document.visibilityState == "visible");
|
||||
@ -80,7 +79,7 @@ function Live() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full overflow-scroll">
|
||||
{events && events.length > 0 && (
|
||||
<ScrollArea>
|
||||
<TooltipProvider>
|
||||
@ -111,12 +110,12 @@ function Live() {
|
||||
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
|
||||
windowVisible={windowVisible}
|
||||
cameraConfig={camera}
|
||||
preferredLiveMode={safari ? "webrtc" : "mse"}
|
||||
preferredLiveMode={isSafari ? "webrtc" : "mse"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -41,7 +41,7 @@ function Logs() {
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<div className="flex justify-between items-center">
|
||||
<Heading className="first:mt-2" as="h2">
|
||||
Logs
|
||||
@ -76,10 +76,10 @@ function Logs() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto font-mono text-sm bg-secondary rounded my-2 p-2 whitespace-pre-wrap">
|
||||
<div className="absolute left-0 top-16 bottom-2 right-2 overflow-auto font-mono text-sm bg-secondary rounded p-2 whitespace-pre-wrap">
|
||||
{logs}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,34 +1,34 @@
|
||||
import {
|
||||
LuConstruction,
|
||||
LuFileUp,
|
||||
LuFilm,
|
||||
LuVideo,
|
||||
} from "react-icons/lu";
|
||||
LuConstruction,
|
||||
LuFileUp,
|
||||
LuFlag,
|
||||
LuVideo,
|
||||
} from "react-icons/lu";
|
||||
|
||||
export const navbarLinks = [
|
||||
{
|
||||
id: 1,
|
||||
icon: LuVideo,
|
||||
title: "Live",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LuFilm,
|
||||
title: "History",
|
||||
url: "/history",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: LuFileUp,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LuConstruction,
|
||||
title: "UI Playground",
|
||||
url: "/playground",
|
||||
dev: true,
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 1,
|
||||
icon: LuVideo,
|
||||
title: "Live",
|
||||
url: "/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LuFlag,
|
||||
title: "Events",
|
||||
url: "/events",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: LuFileUp,
|
||||
title: "Export",
|
||||
url: "/export",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: LuConstruction,
|
||||
title: "UI Playground",
|
||||
url: "/playground",
|
||||
dev: true,
|
||||
},
|
||||
];
|
||||
|
@ -1,20 +1,21 @@
|
||||
export interface ReviewSegment {
|
||||
id: string;
|
||||
camera: string;
|
||||
severity: ReviewSeverity;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
thumb_path: string;
|
||||
has_been_reviewed: boolean;
|
||||
data: ReviewData;
|
||||
}
|
||||
id: string;
|
||||
camera: string;
|
||||
severity: ReviewSeverity;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
thumb_path: string;
|
||||
has_been_reviewed: boolean;
|
||||
data: ReviewData;
|
||||
}
|
||||
|
||||
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
|
||||
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
|
||||
|
||||
export type ReviewData = {
|
||||
audio: string[];
|
||||
detections: string[];
|
||||
objects: string[];
|
||||
significant_motion_areas: number[];
|
||||
zones: string[];
|
||||
};
|
||||
export type ReviewData = {
|
||||
audio: string[];
|
||||
detections: string[];
|
||||
objects: string[];
|
||||
sub_labels?: string[];
|
||||
significant_motion_areas: number[];
|
||||
zones: string[];
|
||||
};
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function isSafari() {
|
||||
return useMemo(() => {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
}, []);
|
||||
}
|
49
web/src/utils/iconUtil.tsx
Normal file
49
web/src/utils/iconUtil.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { BsPersonWalking } from "react-icons/bs";
|
||||
import {
|
||||
FaAmazon,
|
||||
FaCarSide,
|
||||
FaCat,
|
||||
FaDog,
|
||||
FaFedex,
|
||||
FaFire,
|
||||
FaUps,
|
||||
} from "react-icons/fa";
|
||||
import { LuBox, LuLassoSelect } from "react-icons/lu";
|
||||
import { MdRecordVoiceOver } from "react-icons/md";
|
||||
|
||||
export function getIconForLabel(label: string, className?: string) {
|
||||
switch (label) {
|
||||
case "car":
|
||||
return <FaCarSide key={label} className={className} />;
|
||||
case "cat":
|
||||
return <FaCat key={label} className={className} />;
|
||||
case "bark":
|
||||
case "dog":
|
||||
return <FaDog key={label} className={className} />;
|
||||
case "fire_alarm":
|
||||
return <FaFire key={label} className={className} />;
|
||||
case "package":
|
||||
return <LuBox key={label} className={className} />;
|
||||
case "person":
|
||||
return <BsPersonWalking key={label} className={className} />;
|
||||
case "crying":
|
||||
case "laughter":
|
||||
case "scream":
|
||||
case "speech":
|
||||
case "yell":
|
||||
return <MdRecordVoiceOver key={label} className={className} />;
|
||||
default:
|
||||
return <LuLassoSelect key={label} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIconForSubLabel(label: string, className?: string) {
|
||||
switch (label) {
|
||||
case "amazon":
|
||||
return <FaAmazon key={label} className={className} />;
|
||||
case "fedex":
|
||||
return <FaFedex key={label} className={className} />;
|
||||
case "ups":
|
||||
return <FaUps key={label} className={className} />;
|
||||
}
|
||||
}
|
392
web/src/views/events/DesktopEventView.tsx
Normal file
392
web/src/views/events/DesktopEventView.tsx
Normal file
@ -0,0 +1,392 @@
|
||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuCalendar, LuFilter, LuVideo } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
|
||||
const API_LIMIT = 250;
|
||||
|
||||
export default function DesktopEventView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// review paging
|
||||
|
||||
const [after, setAfter] = useState(0);
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() - 24);
|
||||
setAfter(now.getTime() / 1000);
|
||||
|
||||
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() - 24);
|
||||
setAfter(now.getTime() / 1000);
|
||||
}, 60000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [60000]);
|
||||
|
||||
const reviewSearchParams = {};
|
||||
const reviewSegmentFetcher = useCallback((key: any) => {
|
||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
}, []);
|
||||
|
||||
const getKey = useCallback(
|
||||
(index: number, prevData: ReviewSegment[]) => {
|
||||
if (index > 0) {
|
||||
const lastDate = prevData[prevData.length - 1].start_time;
|
||||
const pagedParams = reviewSearchParams
|
||||
? { before: lastDate, after: after, limit: API_LIMIT }
|
||||
: {
|
||||
...reviewSearchParams,
|
||||
before: lastDate,
|
||||
after: after,
|
||||
limit: API_LIMIT,
|
||||
};
|
||||
return ["review", pagedParams];
|
||||
}
|
||||
|
||||
const params = reviewSearchParams
|
||||
? { limit: API_LIMIT, after: after }
|
||||
: { ...reviewSearchParams, limit: API_LIMIT, after: after };
|
||||
return ["review", params];
|
||||
},
|
||||
[reviewSearchParams]
|
||||
);
|
||||
|
||||
const {
|
||||
data: reviewPages,
|
||||
mutate: updateSegments,
|
||||
size,
|
||||
setSize,
|
||||
isValidating,
|
||||
} = useSWRInfinite<ReviewSegment[]>(getKey, reviewSegmentFetcher);
|
||||
|
||||
const reviewItems = useMemo(() => {
|
||||
const all: ReviewSegment[] = [];
|
||||
const alerts: ReviewSegment[] = [];
|
||||
const detections: ReviewSegment[] = [];
|
||||
const motion: ReviewSegment[] = [];
|
||||
|
||||
reviewPages?.forEach((page) => {
|
||||
page.forEach((segment) => {
|
||||
all.push(segment);
|
||||
|
||||
switch (segment.severity) {
|
||||
case "alert":
|
||||
alerts.push(segment);
|
||||
break;
|
||||
case "detection":
|
||||
detections.push(segment);
|
||||
break;
|
||||
default:
|
||||
motion.push(segment);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
all: all,
|
||||
alert: alerts,
|
||||
detection: detections,
|
||||
significant_motion: motion,
|
||||
};
|
||||
}, [reviewPages]);
|
||||
|
||||
const isDone = useMemo(
|
||||
() => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT,
|
||||
[reviewPages]
|
||||
);
|
||||
|
||||
// review interaction
|
||||
|
||||
const pagingObserver = useRef<IntersectionObserver | null>();
|
||||
const lastReviewRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (isValidating) return;
|
||||
if (pagingObserver.current) pagingObserver.current.disconnect();
|
||||
try {
|
||||
pagingObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isDone) {
|
||||
setSize(size + 1);
|
||||
}
|
||||
});
|
||||
if (node) pagingObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
},
|
||||
[isValidating, isDone]
|
||||
);
|
||||
|
||||
const [minimap, setMinimap] = useState<string[]>([]);
|
||||
const minimapObserver = useRef<IntersectionObserver | null>();
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleTimestamps = new Set<string>();
|
||||
minimapObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const start = (entry.target as HTMLElement).dataset.start;
|
||||
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
visibleTimestamps.add(start);
|
||||
} else {
|
||||
visibleTimestamps.delete(start);
|
||||
}
|
||||
|
||||
setMinimap([...visibleTimestamps]);
|
||||
});
|
||||
},
|
||||
{ root: contentRef.current, threshold: 0.5 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
minimapObserver.current?.disconnect();
|
||||
};
|
||||
}, [contentRef]);
|
||||
const minimapRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!minimapObserver.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node) minimapObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
},
|
||||
[minimapObserver.current]
|
||||
);
|
||||
const minimapBounds = useMemo(() => {
|
||||
const data = {
|
||||
start: Math.floor(Date.now() / 1000) - 35 * 60,
|
||||
end: Math.floor(Date.now() / 1000) - 21 * 60,
|
||||
};
|
||||
const list = minimap.sort();
|
||||
|
||||
if (list.length > 0) {
|
||||
data.end = parseFloat(list.at(-1)!!);
|
||||
data.start = parseFloat(list[0]);
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [minimap]);
|
||||
|
||||
// review status
|
||||
|
||||
const setReviewed = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await axios.post(`review/${id}/viewed`);
|
||||
|
||||
if (resp.status == 200) {
|
||||
updateSegments();
|
||||
}
|
||||
},
|
||||
[updateSegments]
|
||||
);
|
||||
|
||||
// preview videos
|
||||
|
||||
const previewTimes = useMemo(() => {
|
||||
if (
|
||||
!reviewPages ||
|
||||
reviewPages.length == 0 ||
|
||||
reviewPages.at(-1)!!.length == 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setMinutes(0, 0, 0);
|
||||
|
||||
const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
start: startDate.getTime() / 1000,
|
||||
end: endDate.getTime() / 1000,
|
||||
};
|
||||
}, [reviewPages]);
|
||||
const { data: allPreviews } = useSWR<Preview[]>(
|
||||
previewTimes
|
||||
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
|
||||
: null,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
console.log("end of the timeline is " + after + " vs " + (Math.floor(Date.now() / 1000) + 2 * 60 * 60))
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div className="absolute flex justify-between left-0 top-0 right-0">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
defaultValue="alert"
|
||||
size="sm"
|
||||
onValueChange={(value: ReviewSeverity) => setSeverity(value)}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "alert" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="alert"
|
||||
aria-label="Select alerts"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" />
|
||||
Alerts
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "detection" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="detection"
|
||||
aria-label="Select detections"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" />
|
||||
Detections
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "significant_motion" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="significant_motion"
|
||||
aria-label="Select motion"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" />
|
||||
Motion
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div>
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuVideo className=" mr-[10px]" />
|
||||
All Cameras
|
||||
</Button>
|
||||
<ReviewCalendarButton />
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuFilter className=" mr-[10px]" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="absolute left-0 top-12 bottom-0 right-28 flex flex-wrap content-start gap-2 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{reviewItems[severity]?.map((value, segIdx) => {
|
||||
const lastRow = segIdx == reviewItems[severity].length - 1;
|
||||
const relevantPreview = Object.values(allPreviews || []).find(
|
||||
(preview) =>
|
||||
preview.camera == value.camera &&
|
||||
preview.start < value.start_time &&
|
||||
preview.end > value.end_time
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
ref={lastRow ? lastReviewRef : minimapRef}
|
||||
data-start={value.start_time}
|
||||
>
|
||||
<div className="h-[234px] aspect-video rounded-lg overflow-hidden">
|
||||
<PreviewThumbnailPlayer
|
||||
review={value}
|
||||
relevantPreview={relevantPreview}
|
||||
setReviewed={() => setReviewed(value.id)}
|
||||
/>
|
||||
</div>
|
||||
{lastRow && !isDone && <ActivityIndicator />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute top-12 right-0 bottom-0">
|
||||
{after != 0 && (<EventReviewTimeline
|
||||
segmentDuration={60}
|
||||
timestampSpread={15}
|
||||
timelineStart={Math.floor(Date.now() / 1000)} // start of the timeline - all times are numeric, not Date objects
|
||||
timelineEnd={after} // end of timeline - timestamp
|
||||
showMinimap
|
||||
minimapStartTime={minimapBounds.start}
|
||||
minimapEndTime={minimapBounds.end}
|
||||
events={reviewItems.all}
|
||||
severityType={severity}
|
||||
contentRef={contentRef}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* <EventReviewTimeline
|
||||
segmentDuration={60} // seconds per segment
|
||||
timestampSpread={15} // minutes between each major timestamp
|
||||
timelineStart={Math.floor(Date.now() / 1000)} // start of the timeline - all times are numeric, not Date objects
|
||||
timelineEnd={Math.floor(Date.now() / 1000) + 2 * 60 * 60} // end of timeline - timestamp
|
||||
showHandlebar // show / hide the handlebar
|
||||
handlebarTime={Math.floor(Date.now() / 1000) - 27 * 60} // set the time of the handlebar
|
||||
showMinimap // show / hide the minimap
|
||||
minimapStartTime={Math.floor(Date.now() / 1000) - 35 * 60} // start time of the minimap - the earlier time (eg 1:00pm)
|
||||
minimapEndTime={Math.floor(Date.now() / 1000) - 21 * 60} // end of the minimap - the later time (eg 3:00pm)
|
||||
events={mockEvents} // events, including new has_been_reviewed and severity properties
|
||||
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
|
||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||
/>
|
||||
*/
|
||||
|
||||
function ReviewCalendarButton() {
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||
const future = new Date();
|
||||
future.setFullYear(tomorrow.getFullYear() + 10);
|
||||
return { from: tomorrow, to: future };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="mx-1" variant="secondary">
|
||||
<LuCalendar className=" mr-[10px]" />
|
||||
{formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||
strftime_fmt: "%b %-d",
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Calendar mode="single" disabled={disabledDates} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
311
web/src/views/events/MobileEventView.tsx
Normal file
311
web/src/views/events/MobileEventView.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
|
||||
const API_LIMIT = 250;
|
||||
|
||||
export default function MobileEventView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// review paging
|
||||
|
||||
const [after, setAfter] = useState(0);
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() - 24);
|
||||
setAfter(now.getTime() / 1000);
|
||||
|
||||
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() - 24);
|
||||
setAfter(now.getTime() / 1000);
|
||||
}, 60000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [60000]);
|
||||
|
||||
const reviewSearchParams = {};
|
||||
const reviewSegmentFetcher = useCallback((key: any) => {
|
||||
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
||||
return axios.get(path, { params }).then((res) => res.data);
|
||||
}, []);
|
||||
|
||||
const getKey = useCallback(
|
||||
(index: number, prevData: ReviewSegment[]) => {
|
||||
if (index > 0) {
|
||||
const lastDate = prevData[prevData.length - 1].start_time;
|
||||
const pagedParams = reviewSearchParams
|
||||
? { before: lastDate, after: after, limit: API_LIMIT }
|
||||
: {
|
||||
...reviewSearchParams,
|
||||
before: lastDate,
|
||||
after: after,
|
||||
limit: API_LIMIT,
|
||||
};
|
||||
return ["review", pagedParams];
|
||||
}
|
||||
|
||||
const params = reviewSearchParams
|
||||
? { limit: API_LIMIT, after: after }
|
||||
: { ...reviewSearchParams, limit: API_LIMIT, after: after };
|
||||
return ["review", params];
|
||||
},
|
||||
[reviewSearchParams]
|
||||
);
|
||||
|
||||
const {
|
||||
data: reviewPages,
|
||||
mutate: updateSegments,
|
||||
size,
|
||||
setSize,
|
||||
isValidating,
|
||||
} = useSWRInfinite<ReviewSegment[]>(getKey, reviewSegmentFetcher);
|
||||
|
||||
const reviewItems = useMemo(() => {
|
||||
const all: ReviewSegment[] = [];
|
||||
const alerts: ReviewSegment[] = [];
|
||||
const detections: ReviewSegment[] = [];
|
||||
const motion: ReviewSegment[] = [];
|
||||
|
||||
reviewPages?.forEach((page) => {
|
||||
page.forEach((segment) => {
|
||||
all.push(segment);
|
||||
|
||||
switch (segment.severity) {
|
||||
case "alert":
|
||||
alerts.push(segment);
|
||||
break;
|
||||
case "detection":
|
||||
detections.push(segment);
|
||||
break;
|
||||
default:
|
||||
motion.push(segment);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
all: all,
|
||||
alert: alerts,
|
||||
detection: detections,
|
||||
significant_motion: motion,
|
||||
};
|
||||
}, [reviewPages]);
|
||||
|
||||
const isDone = useMemo(
|
||||
() => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT,
|
||||
[reviewPages]
|
||||
);
|
||||
|
||||
// review interaction
|
||||
|
||||
const pagingObserver = useRef<IntersectionObserver | null>();
|
||||
const lastReviewRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (isValidating) return;
|
||||
if (pagingObserver.current) pagingObserver.current.disconnect();
|
||||
try {
|
||||
pagingObserver.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isDone) {
|
||||
setSize(size + 1);
|
||||
}
|
||||
});
|
||||
if (node) pagingObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
},
|
||||
[isValidating, isDone]
|
||||
);
|
||||
|
||||
const [minimap, setMinimap] = useState<string[]>([]);
|
||||
const minimapObserver = useRef<IntersectionObserver | null>();
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleTimestamps = new Set<string>();
|
||||
minimapObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const start = (entry.target as HTMLElement).dataset.start;
|
||||
|
||||
if (!start) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
visibleTimestamps.add(start);
|
||||
} else {
|
||||
visibleTimestamps.delete(start);
|
||||
}
|
||||
|
||||
setMinimap([...visibleTimestamps]);
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
minimapObserver.current?.disconnect();
|
||||
};
|
||||
}, [contentRef]);
|
||||
const minimapRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (!minimapObserver.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node) minimapObserver.current.observe(node);
|
||||
} catch (e) {
|
||||
// no op
|
||||
}
|
||||
},
|
||||
[minimapObserver.current]
|
||||
);
|
||||
const minimapBounds = useMemo(() => {
|
||||
const data = {
|
||||
start: Math.floor(Date.now() / 1000) - 35 * 60,
|
||||
end: Math.floor(Date.now() / 1000) - 21 * 60,
|
||||
};
|
||||
const list = minimap.sort();
|
||||
|
||||
if (list.length > 0) {
|
||||
data.end = parseFloat(list.at(-1)!!);
|
||||
data.start = parseFloat(list[0]);
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [minimap]);
|
||||
|
||||
// review status
|
||||
|
||||
const setReviewed = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await axios.post(`review/${id}/viewed`);
|
||||
|
||||
if (resp.status == 200) {
|
||||
updateSegments();
|
||||
}
|
||||
},
|
||||
[updateSegments]
|
||||
);
|
||||
|
||||
// preview videos
|
||||
|
||||
const previewTimes = useMemo(() => {
|
||||
if (
|
||||
!reviewPages ||
|
||||
reviewPages.length == 0 ||
|
||||
reviewPages.at(-1)!!.length == 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setMinutes(0, 0, 0);
|
||||
|
||||
const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
start: startDate.getTime() / 1000,
|
||||
end: endDate.getTime() / 1000,
|
||||
};
|
||||
}, [reviewPages]);
|
||||
const { data: allPreviews } = useSWR<Preview[]>(
|
||||
previewTimes
|
||||
? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}`
|
||||
: null,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
defaultValue="alert"
|
||||
size="sm"
|
||||
onValueChange={(value: ReviewSeverity) => setSeverity(value)}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "alert" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="alert"
|
||||
aria-label="Select alerts"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_alert" />
|
||||
Alerts
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "detection" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="detection"
|
||||
aria-label="Select detections"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_detection" />
|
||||
Detections
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
className={`px-3 py-4 rounded-2xl ${
|
||||
severity == "significant_motion" ? "" : "text-gray-500"
|
||||
}`}
|
||||
value="significant_motion"
|
||||
aria-label="Select motion"
|
||||
>
|
||||
<MdCircle className="w-2 h-2 mr-[10px] text-severity_motion" />
|
||||
Motion
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="w-full h-full grid grid-cols-1 sm:grid-cols-2 mt-2 gap-2 overflow-y-auto"
|
||||
>
|
||||
{reviewItems[severity]?.map((value, segIdx) => {
|
||||
const lastRow = segIdx == reviewItems[severity].length - 1;
|
||||
const relevantPreview = Object.values(allPreviews || []).find(
|
||||
(preview) =>
|
||||
preview.camera == value.camera &&
|
||||
preview.start < value.start_time &&
|
||||
preview.end > value.end_time
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
ref={lastRow ? lastReviewRef : minimapRef}
|
||||
data-start={value.start_time}
|
||||
>
|
||||
<div className="w-full aspect-video rounded-lg overflow-hidden">
|
||||
<PreviewThumbnailPlayer
|
||||
review={value}
|
||||
relevantPreview={relevantPreview}
|
||||
autoPlayback={minimapBounds.end == value.start_time}
|
||||
setReviewed={() => setReviewed(value.id)}
|
||||
/>
|
||||
</div>
|
||||
{lastRow && !isDone && <ActivityIndicator />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user