diff --git a/web/src/components/card/HistoryCard.tsx b/web/src/components/card/HistoryCard.tsx index ccacefa20..114eb1976 100644 --- a/web/src/components/card/HistoryCard.tsx +++ b/web/src/components/card/HistoryCard.tsx @@ -47,7 +47,7 @@ export default function HistoryCard({ {formatUnixTimestampToDateTime(timeline.time, { strftime_fmt: - config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S", + config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p", time_style: "medium", date_style: "medium", })} diff --git a/web/src/components/card/MiniEventCard.tsx b/web/src/components/card/MiniEventCard.tsx index fa5aeefeb..2794255eb 100644 --- a/web/src/components/card/MiniEventCard.tsx +++ b/web/src/components/card/MiniEventCard.tsx @@ -61,7 +61,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
-
+
@@ -70,7 +70,7 @@ export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) { {event.camera.replaceAll("_", " ")}
{event.zones.length ? ( -
+
{event.zones.join(", ").replaceAll("_", " ")}
diff --git a/web/src/components/card/TimelineCardPlayer.tsx b/web/src/components/card/TimelinePlayerCard.tsx similarity index 100% rename from web/src/components/card/TimelineCardPlayer.tsx rename to web/src/components/card/TimelinePlayerCard.tsx diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 12145232d..871ff0e9e 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,10 +1,11 @@ import { FrigateConfig } from "@/types/frigateConfig"; import VideoPlayer from "./VideoPlayer"; import useSWR from "swr"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, 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"; type PreviewPlayerProps = { camera: string; @@ -32,6 +33,9 @@ export default function PreviewThumbnailPlayer({ const { data: config } = useSWR("config"); const playerRef = useRef(null); const apiHost = useApiHost(); + const isSafari = useMemo(() => { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + }, []); const [visible, setVisible] = useState(false); @@ -85,7 +89,12 @@ export default function PreviewThumbnailPlayer({ onPlayback(false); } }, - { threshold: 1.0 } + { + threshold: 1.0, + root: document.getElementById("pageRoot"), + // iOS has bug where poster is empty frame until video starts playing so playback needs to begin earlier + rootMargin: isSafari ? "10% 0px 25% 0px" : "0px", + } ); if (node) autoPlayObserver.current.observe(node); } catch (e) { @@ -97,7 +106,10 @@ export default function PreviewThumbnailPlayer({ ); let content; - if (!relevantPreview || !visible) { + + if (relevantPreview && !visible) { + content =
; + } else if (!relevantPreview) { if (isCurrentHour(startTs)) { content = ( ); + } else { + content = ( + + ); } - - content = ( - - ); } else { content = ( -
- { - playerRef.current = player; - player.playbackRate(8); - player.currentTime(startTs - relevantPreview.start); - }} - onDispose={() => { - playerRef.current = null; - }} - /> -
+ <> +
+ { + playerRef.current = player; + player.playbackRate(isSafari ? 2 : 8); + player.currentTime(startTs - relevantPreview.start); + }} + onDispose={() => { + playerRef.current = null; + }} + /> +
+ + ); } diff --git a/web/src/components/player/VideoPlayer.tsx b/web/src/components/player/VideoPlayer.tsx index 20a317caf..9cbd7a7ce 100644 --- a/web/src/components/player/VideoPlayer.tsx +++ b/web/src/components/player/VideoPlayer.tsx @@ -51,7 +51,7 @@ export default function VideoPlayer({ ) as HTMLVideoElement; videoElement.controls = true; videoElement.playsInline = true; - videoElement.disableRemotePlayback = remotePlayback; + videoElement.disableRemotePlayback = !remotePlayback; videoElement.classList.add("small-player"); videoElement.classList.add("video-js"); videoElement.classList.add("vjs-default-skin"); diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index 59c7bb016..9ae1cee82 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -21,8 +21,9 @@ function ConfigEditor() { const [success, setSuccess] = useState(); const [error, setError] = useState(); - const editorRef = useRef(); - const modelRef = useRef(); + const editorRef = useRef(null); + const modelRef = useRef(null); + const configRef = useRef(null); const onHandleSaveConfig = useCallback( async (save_option: SaveOptions) => { @@ -72,6 +73,7 @@ function ConfigEditor() { if (modelRef.current != null) { // we don't need to recreate the editor if it already exists + editorRef.current?.layout(); return; } @@ -97,9 +99,9 @@ function ConfigEditor() { ], }); - const container = document.getElementById("container"); + const container = configRef.current; - if (container != undefined) { + if (container != null) { editorRef.current = monaco.editor.create(container, { language: "yaml", model: modelRef.current, @@ -107,6 +109,12 @@ function ConfigEditor() { theme: theme == "dark" ? "vs-dark" : "vs-light", }); } + + return () => { + configRef.current = null; + editorRef.current = null; + modelRef.current = null; + }; }); if (!config) { @@ -149,7 +157,7 @@ function ConfigEditor() {
)} -
+
); } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index b214aef40..7ad79bb94 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -18,18 +18,15 @@ import { FaWalking } from "react-icons/fa"; import { LuEar } from "react-icons/lu"; import { TbMovie } from "react-icons/tb"; import MiniEventCard from "@/components/card/MiniEventCard"; -import { Event } from "@/types/event"; +import { Event as FrigateEvent } from "@/types/event"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; export function Dashboard() { const { data: config } = useSWR("config"); - - const recentTimestamp = useMemo(() => { - const now = new Date(); - now.setMinutes(now.getMinutes() - 30); - return now.getTime() / 1000; - }, []); - const { data: events, mutate: updateEvents } = useSWR([ + const now = new Date(); + now.setMinutes(now.getMinutes() - 30, 0, 0); + const recentTimestamp = now.getTime() / 1000; + const { data: events, mutate: updateEvents } = useSWR([ "events", { limit: 10, after: recentTimestamp }, ]); @@ -97,7 +94,7 @@ function Camera({ camera }: { camera: CameraConfig }) { return ( <> - + sendDetect(detectValue == "ON" ? "OFF" : "ON")} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + sendDetect(detectValue == "ON" ? "OFF" : "ON"); + }} > @@ -130,11 +131,13 @@ function Camera({ camera }: { camera: CameraConfig }) { : "text-gray-400" : "text-red-500" } - onClick={() => + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); camera.record.enabled_in_config ? sendRecord(recordValue == "ON" ? "OFF" : "ON") - : {} - } + : {}; + }} > @@ -144,7 +147,11 @@ function Camera({ camera }: { camera: CameraConfig }) { className={`${ snapshotValue == "ON" ? "text-primary" : "text-gray-400" }`} - onClick={() => sendSnapshot(detectValue == "ON" ? "OFF" : "ON")} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + sendSnapshot(detectValue == "ON" ? "OFF" : "ON"); + }} > @@ -155,7 +162,11 @@ function Camera({ camera }: { camera: CameraConfig }) { className={`${ audioValue == "ON" ? "text-primary" : "text-gray-400" }`} - onClick={() => sendAudio(detectValue == "ON" ? "OFF" : "ON")} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + sendAudio(detectValue == "ON" ? "OFF" : "ON"); + }} > diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index eaf908dc9..e2342e423 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -7,9 +7,10 @@ import ActivityIndicator from "@/components/ui/activity-indicator"; import HistoryCard from "@/components/card/HistoryCard"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import axios from "axios"; -import TimelinePlayerCard from "@/components/card/TimelineCardPlayer"; +import TimelinePlayerCard from "@/components/card/TimelinePlayerCard"; +import { getHourlyTimelineData } from "@/utils/historyUtil"; -const API_LIMIT = 120; +const API_LIMIT = 200; function History() { const { data: config } = useSWR("config"); @@ -59,83 +60,7 @@ function History() { return []; } - const cards: CardsData = {}; - timelinePages.forEach((hourlyTimeline) => { - Object.keys(hourlyTimeline["hours"]) - .reverse() - .forEach((hour) => { - const day = new Date(parseInt(hour) * 1000); - day.setHours(0, 0, 0, 0); - const dayKey = (day.getTime() / 1000).toString(); - const source_to_types: { [key: string]: string[] } = {}; - Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { - const time = new Date(i.timestamp * 1000); - time.setSeconds(0); - time.setMilliseconds(0); - const key = `${i.source_id}-${time.getMinutes()}`; - if (key in source_to_types) { - source_to_types[key].push(i.class_type); - } else { - source_to_types[key] = [i.class_type]; - } - }); - - if (!Object.keys(cards).includes(dayKey)) { - cards[dayKey] = {}; - } - cards[dayKey][hour] = {}; - Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { - const time = new Date(i.timestamp * 1000); - const key = `${i.camera}-${time.getMinutes()}`; - - // detail level for saving items - // detail level determines which timeline items for each moment is returned - // values can be normal, extra, or full - // normal: return all items except active / attribute / gone / stationary / visible unless that is the only item. - // extra: return all items except attribute / gone / visible unless that is the only item - // full: return all items - - let add = true; - if (detailLevel == "normal") { - if ( - source_to_types[`${i.source_id}-${time.getMinutes()}`].length > - 1 && - [ - "active", - "attribute", - "gone", - "stationary", - "visible", - ].includes(i.class_type) - ) { - add = false; - } - } else if (detailLevel == "extra") { - if ( - source_to_types[`${i.source_id}-${time.getMinutes()}`].length > - 1 && - i.class_type in ["attribute", "gone", "visible"] - ) { - add = false; - } - } - - if (add) { - if (key in cards[dayKey][hour]) { - cards[dayKey][hour][key].entries.push(i); - } else { - cards[dayKey][hour][key] = { - camera: i.camera, - time: time.getTime() / 1000, - entries: [i], - }; - } - } - }); - }); - }); - - return cards; + return getHourlyTimelineData(timelinePages, detailLevel); }, [detailLevel, timelinePages]); const isDone = @@ -168,9 +93,6 @@ function History() { return ( <> Review -
- Dates and times are based on the timezone {timezone} -
{ return (
- + {formatUnixTimestampToDateTime(parseInt(day), { strftime_fmt: "%A %b %d", time_style: "medium", @@ -206,7 +131,10 @@ function History() {
{formatUnixTimestampToDateTime(parseInt(hour), { - strftime_fmt: "%I:00", + strftime_fmt: + config.ui.time_format == "24hour" + ? "%H:00" + : "%I:00 %p", time_style: "medium", date_style: "medium", })} @@ -229,6 +157,7 @@ function History() { preview.end > startTs ); } + return ( { + Object.keys(hourlyTimeline["hours"]) + .reverse() + .forEach((hour) => { + const day = new Date(parseInt(hour) * 1000); + day.setHours(0, 0, 0, 0); + const dayKey = (day.getTime() / 1000).toString(); + + // build a map of course to the types that are included in this hour + // which allows us to know what items to keep depending on detail level + const source_to_types: { [key: string]: string[] } = {}; + let cardTypeStart: { [camera: string]: number } = {}; + Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { + if (i.timestamp > (cardTypeStart[i.camera] ?? 0) + GROUP_SECONDS) { + cardTypeStart[i.camera] = i.timestamp; + } + + const groupKey = `${i.source_id}-${cardTypeStart[i.camera]}`; + + if (groupKey in source_to_types) { + source_to_types[groupKey].push(i.class_type); + } else { + source_to_types[groupKey] = [i.class_type]; + } + }); + + if (!(dayKey in cards)) { + cards[dayKey] = {}; + } + + if (!(hour in cards[dayKey])) { + cards[dayKey][hour] = {}; + } + + let cardStart: { [camera: string]: number } = {}; + Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { + if (i.timestamp > (cardStart[i.camera] ?? 0) + GROUP_SECONDS) { + cardStart[i.camera] = i.timestamp; + } + + const time = new Date(i.timestamp * 1000); + const groupKey = `${i.camera}-${cardStart[i.camera]}`; + const sourceKey = `${i.source_id}-${cardStart[i.camera]}`; + const uniqueKey = `${i.source_id}-${i.class_type}`; + + // detail level for saving items + // detail level determines which timeline items for each moment is returned + // values can be normal, extra, or full + // normal: return all items except active / attribute / gone / stationary / visible unless that is the only item. + // extra: return all items except attribute / gone / visible unless that is the only item + // full: return all items + + let add = true; + if (detailLevel == "normal") { + if ( + source_to_types[sourceKey].length > 1 && + ["active", "attribute", "gone", "stationary", "visible"].includes( + i.class_type + ) + ) { + add = false; + } + } else if (detailLevel == "extra") { + if ( + source_to_types[sourceKey].length > 1 && + i.class_type in ["attribute", "gone", "visible"] + ) { + add = false; + } + } + + if (add) { + if (groupKey in cards[dayKey][hour]) { + if ( + !cards[dayKey][hour][groupKey].uniqueKeys.includes(uniqueKey) || + detailLevel == "full" + ) { + cards[dayKey][hour][groupKey].entries.push(i); + cards[dayKey][hour][groupKey].uniqueKeys.push(uniqueKey); + } + } else { + cards[dayKey][hour][groupKey] = { + camera: i.camera, + time: time.getTime() / 1000, + entries: [i], + uniqueKeys: [uniqueKey], + }; + } + } + }); + }); + }); + + return cards; +} diff --git a/web/themes/theme-blue.css b/web/themes/theme-blue.css index 19bc40364..e07ac2c30 100644 --- a/web/themes/theme-blue.css +++ b/web/themes/theme-blue.css @@ -39,7 +39,7 @@ --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; + --border: 217.2 36.6% 12.5%; --input: 217.2 38.6% 29.5%; --ring: 224.3 76.3% 48%; }