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])
|
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(
|
@bp.route(
|
||||||
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
|
"/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-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@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",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"apexcharts": "^3.45.1",
|
"apexcharts": "^3.45.1",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
@ -38,6 +40,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-apexcharts": "^1.4.1",
|
"react-apexcharts": "^1.4.1",
|
||||||
"react-day-picker": "^8.9.1",
|
"react-day-picker": "^8.9.1",
|
||||||
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-icons": "^4.12.0",
|
"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": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz",
|
"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"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
@ -8062,6 +8131,28 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/ufo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",
|
"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-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@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",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"apexcharts": "^3.45.1",
|
"apexcharts": "^3.45.1",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
@ -43,6 +45,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-apexcharts": "^1.4.1",
|
"react-apexcharts": "^1.4.1",
|
||||||
"react-day-picker": "^8.9.1",
|
"react-day-picker": "^8.9.1",
|
||||||
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
|
@ -14,6 +14,7 @@ import Logs from "@/pages/Logs";
|
|||||||
import NoMatch from "@/pages/NoMatch";
|
import NoMatch from "@/pages/NoMatch";
|
||||||
import Settings from "@/pages/Settings";
|
import Settings from "@/pages/Settings";
|
||||||
import UIPlayground from "./pages/UIPlayground";
|
import UIPlayground from "./pages/UIPlayground";
|
||||||
|
import Events from "./pages/Events";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
@ -27,14 +28,15 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Header onToggleNavbar={toggleNavbar} />
|
<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} />
|
<Sidebar sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} />
|
||||||
<div
|
<div
|
||||||
id="pageRoot"
|
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>
|
<Routes>
|
||||||
<Route path="/" element={<Live />} />
|
<Route path="/" element={<Live />} />
|
||||||
|
<Route path="/events" element={<Events />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
<Route path="/export" element={<Export />} />
|
<Route path="/export" element={<Export />} />
|
||||||
<Route path="/storage" element={<Storage />} />
|
<Route path="/storage" element={<Storage />} />
|
||||||
|
@ -5,7 +5,7 @@ type TWrapperProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = ({ children }: 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;
|
export default Wrapper;
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
|
||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import ActivityIndicator from "../ui/activity-indicator";
|
import ActivityIndicator from "../ui/activity-indicator";
|
||||||
@ -21,8 +20,10 @@ type HistoryCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function HistoryCard({
|
export default function HistoryCard({
|
||||||
|
// @ts-ignore
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
timeline,
|
timeline,
|
||||||
|
// @ts-ignore
|
||||||
isMobile,
|
isMobile,
|
||||||
onClick,
|
onClick,
|
||||||
onDelete,
|
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]"
|
className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
|
||||||
onClick={onClick}
|
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="text-sm flex justify-between items-center">
|
||||||
<div className="pl-1 pt-1">
|
<div className="pl-1 pt-1">
|
||||||
|
@ -2,23 +2,26 @@ import VideoPlayer from "./VideoPlayer";
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { LuPlayCircle } from "react-icons/lu";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { Slider } from "../ui/slider";
|
||||||
import { isSafari } from "@/utils/browserUtil";
|
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 = {
|
type PreviewPlayerProps = {
|
||||||
camera: string;
|
review: ReviewSegment;
|
||||||
relevantPreview?: Preview;
|
relevantPreview?: Preview;
|
||||||
startTs: number;
|
autoPlayback?: boolean;
|
||||||
eventId: string;
|
setReviewed?: () => void;
|
||||||
isMobile: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Preview = {
|
type Preview = {
|
||||||
@ -30,17 +33,43 @@ type Preview = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PreviewThumbnailPlayer({
|
export default function PreviewThumbnailPlayer({
|
||||||
camera,
|
review,
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
startTs,
|
autoPlayback = false,
|
||||||
eventId,
|
setReviewed,
|
||||||
isMobile,
|
|
||||||
onClick,
|
|
||||||
}: PreviewPlayerProps) {
|
}: PreviewPlayerProps) {
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const playerRef = useRef<Player | null>(null);
|
const playerRef = useRef<Player | null>(null);
|
||||||
|
|
||||||
const [visible, setVisible] = useState(false);
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
||||||
const [isInitiallyVisible, setIsInitiallyVisible] = useState(false);
|
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(
|
const onPlayback = useCallback(
|
||||||
(isHovered: Boolean) => {
|
(isHovered: Boolean) => {
|
||||||
@ -48,205 +77,181 @@ export default function PreviewThumbnailPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playerRef.current) {
|
|
||||||
if (isHovered) {
|
|
||||||
setIsInitiallyVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHovered) {
|
if (isHovered) {
|
||||||
playerRef.current.play();
|
setHoverTimeout(
|
||||||
|
setTimeout(() => {
|
||||||
|
setPlayback(true);
|
||||||
|
setHoverTimeout(null);
|
||||||
|
}, 500)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
playerRef.current.pause();
|
if (hoverTimeout) {
|
||||||
playerRef.current.currentTime(startTs - relevantPreview.start);
|
clearTimeout(hoverTimeout);
|
||||||
}
|
|
||||||
},
|
|
||||||
[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 (isMobile && !autoPlayObserver.current) {
|
setPlayback(false);
|
||||||
try {
|
setProgress(0);
|
||||||
autoPlayObserver.current = new IntersectionObserver(
|
|
||||||
(entries) => {
|
if (playerRef.current) {
|
||||||
const [{ isIntersecting }] = entries;
|
playerRef.current.pause();
|
||||||
if (isIntersecting) {
|
playerRef.current.currentTime(
|
||||||
onPlayback(true);
|
review.start_time - relevantPreview.start
|
||||||
} else {
|
|
||||||
onPlayback(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: 1.0,
|
|
||||||
root: document.getElementById("pageRoot"),
|
|
||||||
rootMargin: "-10% 0px -25% 0px",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (node) autoPlayObserver.current.observe(node);
|
|
||||||
} catch (e) {
|
|
||||||
// no op
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[preloadObserver, autoPlayObserver, onPlayback]
|
[hoverTimeout, relevantPreview, review, playerRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AspectRatio
|
<div
|
||||||
ref={relevantPreview ? inViewRef : null}
|
className="relative w-full h-full cursor-pointer"
|
||||||
ratio={16 / 9}
|
onMouseEnter={isMobile ? undefined : () => onPlayback(true)}
|
||||||
className="bg-black flex justify-center items-center"
|
onMouseLeave={isMobile ? undefined : () => onPlayback(false)}
|
||||||
onMouseEnter={() => onPlayback(true)}
|
|
||||||
onMouseLeave={() => onPlayback(false)}
|
|
||||||
>
|
>
|
||||||
<PreviewContent
|
{playingBack ? (
|
||||||
playerRef={playerRef}
|
<PreviewContent
|
||||||
relevantPreview={relevantPreview}
|
playerRef={playerRef}
|
||||||
isVisible={visible}
|
review={review}
|
||||||
isInitiallyVisible={isInitiallyVisible}
|
relevantPreview={relevantPreview}
|
||||||
startTs={startTs}
|
playback={playingBack}
|
||||||
camera={camera}
|
setProgress={setProgress}
|
||||||
eventId={eventId}
|
setReviewed={setReviewed}
|
||||||
isMobile={isMobile}
|
/>
|
||||||
onClick={onClick}
|
) : (
|
||||||
/>
|
<img
|
||||||
</AspectRatio>
|
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 = {
|
type PreviewContentProps = {
|
||||||
playerRef: React.MutableRefObject<Player | null>;
|
playerRef: React.MutableRefObject<Player | null>;
|
||||||
camera: string;
|
review: ReviewSegment;
|
||||||
relevantPreview: Preview | undefined;
|
relevantPreview: Preview | undefined;
|
||||||
eventId: string;
|
playback: boolean;
|
||||||
isVisible: boolean;
|
setProgress?: (progress: number) => void;
|
||||||
isInitiallyVisible: boolean;
|
setReviewed?: () => void;
|
||||||
startTs: number;
|
|
||||||
isMobile: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
function PreviewContent({
|
function PreviewContent({
|
||||||
playerRef,
|
playerRef,
|
||||||
camera,
|
review,
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
eventId,
|
playback,
|
||||||
isVisible,
|
setProgress,
|
||||||
isInitiallyVisible,
|
setReviewed,
|
||||||
startTs,
|
|
||||||
isMobile,
|
|
||||||
onClick,
|
|
||||||
}: PreviewContentProps) {
|
}: PreviewContentProps) {
|
||||||
const apiHost = useApiHost();
|
if (relevantPreview && playback) {
|
||||||
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)) {
|
|
||||||
return (
|
return (
|
||||||
<img
|
<VideoPlayer
|
||||||
className="w-[160px]"
|
options={{
|
||||||
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
|
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 currentTimeRef = useRef<HTMLDivElement>(null);
|
||||||
const observer = useRef<ResizeObserver | null>(null);
|
const observer = useRef<ResizeObserver | null>(null);
|
||||||
const timelineDuration = useMemo(
|
const timelineDuration = useMemo(
|
||||||
() => timelineEnd - timelineStart,
|
() => timelineStart - timelineEnd,
|
||||||
[timelineEnd, timelineStart]
|
[timelineEnd, timelineStart]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -208,7 +208,7 @@ export function EventReviewTimeline({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={timelineRef}
|
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"
|
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
const Slider = React.forwardRef<
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
@ -15,12 +15,11 @@ const Slider = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
))
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Slider }
|
export { Slider };
|
||||||
|
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,
|
getReviewed,
|
||||||
shouldShowRoundedCorners,
|
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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Event as FrigateEvent } from "@/types/event";
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { isSafari } from "@/utils/browserUtil";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { isSafari } from "react-device-detect";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
function Live() {
|
function Live() {
|
||||||
@ -65,7 +65,6 @@ function Live() {
|
|||||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const safari = isSafari();
|
|
||||||
const [windowVisible, setWindowVisible] = useState(true);
|
const [windowVisible, setWindowVisible] = useState(true);
|
||||||
const visibilityListener = useCallback(() => {
|
const visibilityListener = useCallback(() => {
|
||||||
setWindowVisible(document.visibilityState == "visible");
|
setWindowVisible(document.visibilityState == "visible");
|
||||||
@ -80,7 +79,7 @@ function Live() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="w-full h-full overflow-scroll">
|
||||||
{events && events.length > 0 && (
|
{events && events.length > 0 && (
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@ -111,12 +110,12 @@ function Live() {
|
|||||||
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
|
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
|
||||||
windowVisible={windowVisible}
|
windowVisible={windowVisible}
|
||||||
cameraConfig={camera}
|
cameraConfig={camera}
|
||||||
preferredLiveMode={safari ? "webrtc" : "mse"}
|
preferredLiveMode={isSafari ? "webrtc" : "mse"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ function Logs() {
|
|||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Heading className="first:mt-2" as="h2">
|
<Heading className="first:mt-2" as="h2">
|
||||||
Logs
|
Logs
|
||||||
@ -76,10 +76,10 @@ function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{logs}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,34 +1,34 @@
|
|||||||
import {
|
import {
|
||||||
LuConstruction,
|
LuConstruction,
|
||||||
LuFileUp,
|
LuFileUp,
|
||||||
LuFilm,
|
LuFlag,
|
||||||
LuVideo,
|
LuVideo,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
|
||||||
export const navbarLinks = [
|
export const navbarLinks = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
icon: LuVideo,
|
icon: LuVideo,
|
||||||
title: "Live",
|
title: "Live",
|
||||||
url: "/",
|
url: "/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
icon: LuFilm,
|
icon: LuFlag,
|
||||||
title: "History",
|
title: "Events",
|
||||||
url: "/history",
|
url: "/events",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
icon: LuFileUp,
|
icon: LuFileUp,
|
||||||
title: "Export",
|
title: "Export",
|
||||||
url: "/export",
|
url: "/export",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
icon: LuConstruction,
|
icon: LuConstruction,
|
||||||
title: "UI Playground",
|
title: "UI Playground",
|
||||||
url: "/playground",
|
url: "/playground",
|
||||||
dev: true,
|
dev: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
export interface ReviewSegment {
|
export interface ReviewSegment {
|
||||||
id: string;
|
id: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
severity: ReviewSeverity;
|
severity: ReviewSeverity;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time: number;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
has_been_reviewed: boolean;
|
has_been_reviewed: boolean;
|
||||||
data: ReviewData;
|
data: ReviewData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
|
export type ReviewSeverity = "alert" | "detection" | "significant_motion";
|
||||||
|
|
||||||
export type ReviewData = {
|
export type ReviewData = {
|
||||||
audio: string[];
|
audio: string[];
|
||||||
detections: string[];
|
detections: string[];
|
||||||
objects: string[];
|
objects: string[];
|
||||||
significant_motion_areas: number[];
|
sub_labels?: string[];
|
||||||
zones: 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