diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index b3c0c36ab..bbdf4a7eb 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -23,6 +23,7 @@ type PreviewPlayerProps = { relevantPreview?: Preview; autoPlayback?: boolean; setReviewed?: () => void; + onClick?: () => void; }; type Preview = { @@ -38,6 +39,7 @@ export default function PreviewThumbnailPlayer({ relevantPreview, autoPlayback = false, setReviewed, + onClick, }: PreviewPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -109,6 +111,7 @@ export default function PreviewThumbnailPlayer({ className="relative w-full h-full cursor-pointer" onMouseEnter={isMobile ? undefined : () => onPlayback(true)} onMouseLeave={isMobile ? undefined : () => onPlayback(false)} + onClick={onClick} > {playingBack ? ( { if (!setProgress) { return; @@ -262,11 +267,14 @@ function PreviewContent({ if ( setReviewed && !review.has_been_reviewed && + lastPercent < 50 && playerPercent > 50 ) { setReviewed(); } + lastPercent = playerPercent; + if (playerPercent > 100) { playerRef.current?.pause(); setManualPlayback(false); diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0a465afe8..f79263178 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,11 +1,140 @@ +import useOverlayState from "@/hooks/use-overlay-state"; +import { ReviewSegment } from "@/types/review"; import DesktopEventView from "@/views/events/DesktopEventView"; +import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import MobileEventView from "@/views/events/MobileEventView"; -import { isMobile } from 'react-device-detect'; +import axios from "axios"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { isMobile } from "react-device-detect"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 250; export default function Events() { - if (isMobile) { - return ; - } + // recordings viewer + const [selectedReview, setSelectedReview] = useOverlayState("review"); - return ; + // review paging + + const [after, setAfter] = useState(getHoursAgo(24)); + useEffect(() => { + const intervalId: NodeJS.Timeout = setInterval(() => { + setAfter(getHoursAgo(24)); + }, 300000); + return () => clearInterval(intervalId); + }, [300000]); + + const reviewSegmentFetcher = useCallback((key: any) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const reviewSearchParams = {}; + 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(getKey, reviewSegmentFetcher); + + const isDone = useMemo( + () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, + [reviewPages] + ); + + // review status + + const markItemAsReviewed = useCallback( + async (reviewId: string) => { + const resp = await axios.post(`review/${reviewId}/viewed`); + + if (resp.status == 200) { + updateSegments( + (data: ReviewSegment[][] | undefined) => { + if (!data) { + return data; + } + + const newData: ReviewSegment[][] = []; + + data.forEach((page) => { + const reviewIndex = page.findIndex((item) => item.id == reviewId); + + if (reviewIndex == -1) { + newData.push([...page]); + } else { + newData.push([ + ...page.slice(0, reviewIndex), + { ...page[reviewIndex], has_been_reviewed: true }, + ...page.slice(reviewIndex + 1), + ]); + } + }); + + return newData; + }, + { revalidate: false } + ); + } + }, + [updateSegments] + ); + + if (selectedReview) { + return ; + } else { + if (isMobile) { + return ( + setSize(size + 1)} + markItemAsReviewed={markItemAsReviewed} + /> + ); + } + + return ( + setSize(size + 1)} + markItemAsReviewed={markItemAsReviewed} + onSelectReview={setSelectedReview} + /> + ); + } +} + +function getHoursAgo(hours: number): number { + const now = new Date(); + now.setHours(now.getHours() - hours); + return now.getTime() / 1000; } diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx index ddf3c0aea..f05855de3 100644 --- a/web/src/views/events/DesktopEventView.tsx +++ b/web/src/views/events/DesktopEventView.tsx @@ -12,73 +12,35 @@ 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() { +type DesktopEventViewProps = { + reviewPages?: ReviewSegment[][]; + timeRange: [number, number]; + reachedEnd: boolean; + isValidating: boolean; + loadNextPage: () => void; + markItemAsReviewed: (reviewId: string) => void; + onSelectReview: (reviewId: string) => void; +}; +export default function DesktopEventView({ + reviewPages, + timeRange, + reachedEnd, + isValidating, + loadNextPage, + markItemAsReviewed, + onSelectReview, +}: DesktopEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); const contentRef = useRef(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(getKey, reviewSegmentFetcher); - const reviewItems = useMemo(() => { const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; @@ -111,11 +73,6 @@ export default function DesktopEventView() { }; }, [reviewPages]); - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages] - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -135,8 +92,8 @@ export default function DesktopEventView() { if (pagingObserver.current) pagingObserver.current.disconnect(); try { pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); + if (entries[0].isIntersecting && !reachedEnd) { + loadNextPage(); } }); if (node) pagingObserver.current.observe(node); @@ -144,7 +101,7 @@ export default function DesktopEventView() { // no op } }, - [isValidating, isDone] + [isValidating, reachedEnd] ); const [minimap, setMinimap] = useState([]); @@ -209,19 +166,6 @@ export default function DesktopEventView() { 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(() => { @@ -331,10 +275,11 @@ export default function DesktopEventView() { setReviewed(value.id)} + setReviewed={() => markItemAsReviewed(value.id)} + onClick={() => onSelectReview(value.id)} /> - {lastRow && !isDone && } + {lastRow && !reachedEnd && } ); }) @@ -343,12 +288,12 @@ export default function DesktopEventView() { )}
- {after != 0 && ( + {timeRange[1] != 0 && ( Hey this is pretty cool
+ ) +} \ No newline at end of file diff --git a/web/src/views/events/MobileEventView.tsx b/web/src/views/events/MobileEventView.tsx index c48d5f4b0..1404af645 100644 --- a/web/src/views/events/MobileEventView.tsx +++ b/web/src/views/events/MobileEventView.tsx @@ -7,68 +7,27 @@ 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() { +type MobileEventViewProps = { + reviewPages?: ReviewSegment[][]; + reachedEnd: boolean; + isValidating: boolean; + loadNextPage: () => void; + markItemAsReviewed: (reviewId: string) => void; +}; +export default function MobileEventView({ + reviewPages, + reachedEnd, + isValidating, + loadNextPage, + markItemAsReviewed, +}: MobileEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); const contentRef = useRef(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(getKey, reviewSegmentFetcher); - const reviewItems = useMemo(() => { const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; @@ -101,11 +60,6 @@ export default function MobileEventView() { }; }, [reviewPages]); - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages] - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -125,8 +79,8 @@ export default function MobileEventView() { if (pagingObserver.current) pagingObserver.current.disconnect(); try { pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); + if (entries[0].isIntersecting && !reachedEnd) { + loadNextPage(); } }); if (node) pagingObserver.current.observe(node); @@ -134,7 +88,7 @@ export default function MobileEventView() { // no op } }, - [isValidating, isDone] + [isValidating, reachedEnd] ); const [minimap, setMinimap] = useState([]); @@ -199,19 +153,6 @@ export default function MobileEventView() { 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(() => { @@ -309,10 +250,10 @@ export default function MobileEventView() { review={value} relevantPreview={relevantPreview} autoPlayback={minimapBounds.end == value.start_time} - setReviewed={() => setReviewed(value.id)} + setReviewed={() => markItemAsReviewed(value.id)} /> - {lastRow && !isDone && } + {lastRow && !reachedEnd && } ); }) diff --git a/web/vite.config.ts b/web/vite.config.ts index a97dbd014..5d5bf8207 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,24 +12,24 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:5000', + target: 'http://192.168.50.106:5000', ws: true, }, '/vod': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/clips': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/exports': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/ws': { - target: 'ws://localhost:5000', + target: 'ws://192.168.50.106:5000', ws: true, }, '/live': { - target: 'ws://localhost:5000', + target: 'ws://192.168.50.106:5000', changeOrigin: true, ws: true, },