diff --git a/docker/Dockerfile b/docker/Dockerfile index 41696e4e9..bbbd9a2af 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,12 +4,12 @@ ARG OPENCV_VERSION ARG FFMPEG_VERSION ARG WHEELS_VERSION ARG UBUNTU_VERSION -FROM roflcoopter/${ARCH}-opencv:${OPENCV_VERSION} as opencv -FROM roflcoopter/${ARCH}-ffmpeg:${FFMPEG_VERSION} as ffmpeg -FROM roflcoopter/${ARCH}-wheels:${WHEELS_VERSION} as wheels +FROM roflcoopter/${ARCH}-opencv:${OPENCV_VERSION} AS opencv +FROM roflcoopter/${ARCH}-ffmpeg:${FFMPEG_VERSION} AS ffmpeg +FROM roflcoopter/${ARCH}-wheels:${WHEELS_VERSION} AS wheels # Build GPAC -FROM roflcoopter/${ARCH}-base:${BASE_VERSION} as gpac +FROM roflcoopter/${ARCH}-base:${BASE_VERSION} AS gpac ENV \ DEBIAN_FRONTEND=noninteractive @@ -40,7 +40,7 @@ RUN \ # Build frontend -FROM node:20.10.0 as frontend +FROM node:20.10.0 AS frontend WORKDIR /frontend diff --git a/docs/src/pages/components-explorer/components/ffmpeg/config.json b/docs/src/pages/components-explorer/components/ffmpeg/config.json index 9ade60ce9..014e6fb8b 100644 --- a/docs/src/pages/components-explorer/components/ffmpeg/config.json +++ b/docs/src/pages/components-explorer/components/ffmpeg/config.json @@ -614,7 +614,8 @@ "Last message repeated", "non-existing PPS 0 referenced", "no frame!", - "decode_slice_header error" + "decode_slice_header error", + "failed to delete old segment" ] }, { diff --git a/frontend/src/components/camera/CameraCard.tsx b/frontend/src/components/camera/CameraCard.tsx index 38dec31ff..04b417941 100644 --- a/frontend/src/components/camera/CameraCard.tsx +++ b/frontend/src/components/camera/CameraCard.tsx @@ -20,12 +20,10 @@ import { CameraNameOverlay } from "components/camera/CameraNameOverlay"; import { FailedCameraCard } from "components/camera/FailedCameraCard"; import { useAuthContext } from "context/AuthContext"; import { ViseronContext } from "context/ViseronContext"; +import { useFirstRender } from "hooks/UseFirstRender"; import useOnScreen from "hooks/UseOnScreen"; import { useCamera } from "lib/api/camera"; -import queryClient from "lib/api/client"; -import { subscribeStates } from "lib/commands"; import * as types from "lib/types"; -import { SubscriptionUnsubscribe } from "lib/websockets"; type OnClick = ( event: React.MouseEvent, @@ -38,7 +36,7 @@ type FailedOnClick = ( ) => void; interface SuccessCameraCardProps { - camera_identifier: string; + camera: types.Camera; buttons?: boolean; compact?: boolean; onClick?: OnClick; @@ -56,51 +54,8 @@ interface CameraCardProps { const blankImage = "data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"; -const useCameraToken = (camera_identifier: string, auth_enabled: boolean) => { - const { connected, connection } = useContext(ViseronContext); - const unsubRef = useRef(null); - - useEffect(() => { - // If auth is disabled, we dont need to sub for tokens - if (!auth_enabled) { - return; - } - const stateChanged = async ( - _stateChangedEvent: types.StateChangedEvent, - ) => { - queryClient.invalidateQueries(["camera", camera_identifier]); - }; - - const unsubscribeEntities = async () => { - if (unsubRef.current) { - await unsubRef.current(); - } - unsubRef.current = null; - }; - - const subcscribeEntities = async () => { - if (connection && connected) { - unsubRef.current = await subscribeStates( - connection, - stateChanged, - `sensor.${camera_identifier}_access_token`, - undefined, - false, - ); - } else if (connection && !connected && unsubRef.current) { - await unsubscribeEntities(); - } - }; - subcscribeEntities(); - // eslint-disable-next-line consistent-return - return () => { - unsubscribeEntities(); - }; - }, [auth_enabled, camera_identifier, connected, connection]); -}; - const SuccessCameraCard = ({ - camera_identifier, + camera, buttons = true, compact = false, onClick, @@ -112,17 +67,14 @@ const SuccessCameraCard = ({ const ref: any = useRef(); const onScreen = useOnScreen(ref); const isVisible = usePageVisibility(); - const [initialRender, setInitialRender] = useState(true); - const cameraQuery = useCamera(camera_identifier, false, { - enabled: connected, - }); + const firstRender = useFirstRender(); const generateSnapshotURL = useCallback( (width = null) => - `/api/v1/camera/${camera_identifier}/snapshot?rand=${(Math.random() + 1) + `/api/v1/camera/${camera.identifier}/snapshot?rand=${(Math.random() + 1) .toString(36) .substring(7)}${width ? `&width=${Math.trunc(width)}` : ""}`, - [camera_identifier], + [camera.identifier], ); const [snapshotURL, setSnapshotURL] = useState({ // Show blank image on start @@ -134,17 +86,12 @@ const SuccessCameraCard = ({ const updateSnapshot = useRef(); const updateImage = useCallback(() => { setSnapshotURL((prevSnapshotURL) => { - if (cameraQuery.isFetching) { - // Dont load new image if we are loading token - return prevSnapshotURL; - } - if (prevSnapshotURL.loading && !initialRender) { + if (prevSnapshotURL.loading && !firstRender) { // Dont load new image if we are still loading return prevSnapshotURL; } - if (initialRender) { + if (firstRender) { // Make sure we show the spinner on the first image fetched. - setInitialRender(false); return { url: generateSnapshotURL( ref.current ? ref.current.offsetWidth : null, @@ -160,18 +107,24 @@ const SuccessCameraCard = ({ loading: true, }; }); - }, [cameraQuery.isFetching, generateSnapshotURL, initialRender]); + }, [firstRender, generateSnapshotURL]); useEffect(() => { // If element is on screen and browser is visible, start interval to fetch images - if (onScreen && isVisible && connected && cameraQuery.isSuccess) { + if ( + onScreen && + isVisible && + connected && + camera.connected && + camera.is_on + ) { updateImage(); updateSnapshot.current = setInterval( () => { updateImage(); }, - cameraQuery.data.still_image_refresh_interval - ? cameraQuery.data.still_image_refresh_interval * 1000 + camera.still_image_refresh_interval + ? camera.still_image_refresh_interval * 1000 : 10000, ); // If element is hidden or browser loses focus, stop updating images @@ -189,12 +142,11 @@ const SuccessCameraCard = ({ isVisible, onScreen, connected, - cameraQuery.isSuccess, - cameraQuery.data, + camera.connected, + camera.is_on, + camera.still_image_refresh_interval, ]); - useCameraToken(camera_identifier, auth.enabled); - return (
- {cameraQuery.data && ( - + {compact ? ( + + ) : ( + + + {camera.name} + + + )} + (onClick as OnClick)(event, camera) : undefined + } + sx={onClick ? null : { pointerEvents: "none" }} > - {compact ? ( - - ) : ( - - - {cameraQuery.data.name} - - - )} - (onClick as OnClick)(event, cameraQuery.data) - : undefined - } - sx={onClick ? null : { pointerEvents: "none" }} - > - - {/* 'alt=""' in combination with textIndent is a neat trick to hide the broken image icon */} - { - setSnapshotURL((prevSnapshotURL) => ({ - ...prevSnapshotURL, - disableSpinner: true, - disableTransition: true, - loading: false, - })); - }} - errorIcon={Image.defaultProps!.loading} - onError={() => { - setSnapshotURL((prevSnapshotURL) => ({ - ...prevSnapshotURL, - disableSpinner: false, - disableTransition: false, - loading: false, - })); - }} - /> - - - {buttons && ( - - - - - - - + + { + setSnapshotURL((prevSnapshotURL) => ({ + ...prevSnapshotURL, + disableSpinner: true, + disableTransition: true, + loading: false, + })); + }} + errorIcon={ + camera.connected && camera.is_on + ? Image.defaultProps!.loading + : null + } + onError={() => { + setSnapshotURL((prevSnapshotURL) => ({ + ...prevSnapshotURL, + disableSpinner: false, + disableTransition: false, + loading: false, + })); + }} + /> + + + {buttons && ( + + + + + + + - - - - - - - - - - - - - )} - - )} + + + + + + + + + + + + + )} +
); }; @@ -331,7 +278,7 @@ export const CameraCard = ({ } return ( = { + position: "absolute", + zIndex: 999, + right: "0px", + top: "0px", + margin: "5px", + fontSize: "0.7rem", + pointerEvents: "none", + userSelect: "none", +}; + +const cameraNameStyles: SxProps = { + textShadow: "rgba(0, 0, 0, 0.88) 0px 0px 4px", + color: "white", +}; + +const iconStyles: SxProps = { + width: "12px", + height: "12px", + marginLeft: 1, +}; + +const StatusIcon = ({ camera }: { camera: types.Camera }) => + camera.is_on ? ( + + ) : ( + + ); + +export const CameraNameOverlay = ({ + camera_identifier, +}: CameraNameOverlayProps) => { + const cameraQuery = useCamera(camera_identifier); + if (!cameraQuery.data) { + return null; + } + const camera = cameraQuery.data; + + const showStatusText = !camera.failed && (!camera.is_on || !camera.connected); + const statusText = + !camera.failed && camera.is_on ? "Disconnected" : "Camera is off"; + + return ( + + + + {camera.name} + + {!camera.failed && } + + {showStatusText && ( + + {statusText} + + )} + + ); }; -export const CameraNameOverlay = ({ camera }: CameraNameOverlayProps) => ( - - {camera.name} - -); diff --git a/frontend/src/components/events/CameraPickerDialog.tsx b/frontend/src/components/events/CameraPickerDialog.tsx index 51aeee007..4d528faad 100644 --- a/frontend/src/components/events/CameraPickerDialog.tsx +++ b/frontend/src/components/events/CameraPickerDialog.tsx @@ -11,18 +11,11 @@ type CameraPickerDialogProps = { open: boolean; setOpen: (open: boolean) => void; cameras: types.CamerasOrFailedCameras; - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; - selectedCamera: types.Camera | types.FailedCamera | null; }; export const CameraPickerDialog = ({ open, setOpen, cameras, - changeSelectedCamera, - selectedCamera, }: CameraPickerDialogProps) => { const handleClose = () => { setOpen(false); @@ -32,12 +25,7 @@ export const CameraPickerDialog = ({ Cameras - + diff --git a/frontend/src/components/events/EventDatePickerDialog.tsx b/frontend/src/components/events/EventDatePickerDialog.tsx index 4c04d107f..d7a07056c 100644 --- a/frontend/src/components/events/EventDatePickerDialog.tsx +++ b/frontend/src/components/events/EventDatePickerDialog.tsx @@ -9,9 +9,11 @@ import { import dayjs, { Dayjs } from "dayjs"; import { useMemo } from "react"; -import { useEventsAmount } from "lib/api/events"; +import { useEventsAmountMultiple } from "lib/api/events"; import * as types from "lib/types"; +import { useCameraStore } from "./utils"; + function HasEvent( props: PickersDayProps & { highlightedDays?: Record }, ) { @@ -71,7 +73,6 @@ type EventDatePickerDialogProps = { open: boolean; setOpen: (open: boolean) => void; date: Dayjs | null; - camera: types.Camera | types.FailedCamera | null; onChange?: ( value: Dayjs | null, context: PickerChangeHandlerContext, @@ -82,13 +83,12 @@ export function EventDatePickerDialog({ open, setOpen, date, - camera, onChange, }: EventDatePickerDialogProps) { - const eventsAmountQuery = useEventsAmount({ - camera_identifier: camera ? camera.identifier : null, + const { selectedCameras } = useCameraStore(); + const eventsAmountQuery = useEventsAmountMultiple({ + camera_identifiers: selectedCameras, utc_offset_minutes: dayjs().utcOffset(), - configOptions: { enabled: !!camera }, }); const highlightedDays = useMemo( () => diff --git a/frontend/src/components/events/EventPlayerCard.tsx b/frontend/src/components/events/EventPlayerCard.tsx index e0c3d0f1b..e873a1791 100644 --- a/frontend/src/components/events/EventPlayerCard.tsx +++ b/frontend/src/components/events/EventPlayerCard.tsx @@ -1,76 +1,347 @@ +import Image from "@jy95/material-ui-image"; import Box from "@mui/material/Box"; -import Card from "@mui/material/Card"; +import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import Hls from "hls.js"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { CameraNameOverlay } from "components/camera/CameraNameOverlay"; import { TimelinePlayer } from "components/events/timeline/TimelinePlayer"; -import { getSrc } from "components/events/utils"; -import VideoPlayerPlaceholder from "components/videoplayer/VideoPlayerPlaceholder"; +import { + getSrc, + playerCardSmMaxHeight, + useFilteredCameras, +} from "components/events/utils"; +import { useResizeObserver } from "hooks/UseResizeObserver"; import * as types from "lib/types"; dayjs.extend(utc); +type GridLayout = { + columns: number; + rows: number; +}; + +// Dont fully understand why we need to subtract 4 from the height +// to keep the players from overflowing the paper +const getContainerHeight = ( + paperRef: React.RefObject, + smBreakpoint: boolean, +) => + smBreakpoint + ? paperRef.current?.clientHeight || 0 + : playerCardSmMaxHeight() - + ((paperRef.current?.offsetHeight || 0) - + (paperRef.current?.clientHeight || 0)) - + 4; + +const calculateCellDimensions = ( + paperRef: React.RefObject, + camera: types.Camera | types.FailedCamera, + gridLayout: GridLayout, + smBreakpoint: boolean, +) => { + const containerWidth = paperRef.current?.clientWidth || 0; + const containerHeight = getContainerHeight(paperRef, smBreakpoint); + const cellWidth = containerWidth / gridLayout.columns; + const cellHeight = containerHeight / gridLayout.rows; + const cameraAspectRatio = camera.width / camera.height; + const cellAspectRatio = cellWidth / cellHeight; + + if (cameraAspectRatio > cellAspectRatio) { + // Video is wider than the cell, fit to width + const width = cellWidth; + const height = cellWidth / cameraAspectRatio; + return { width, height }; + } + // Video is taller than the cell, fit to height + const height = Math.floor(cellHeight); + const width = Math.floor(cellHeight * cameraAspectRatio); + return { width, height }; +}; + +const calculateLayout = ( + paperRef: React.RefObject, + cameras: types.CamerasOrFailedCameras, + smBreakpoint: boolean, +) => { + if (!paperRef.current) return { columns: 1, rows: 1 }; + + const containerWidth = paperRef.current.clientWidth; + const containerHeight = getContainerHeight(paperRef, smBreakpoint); + const camerasLength = Object.keys(cameras).length; + + let bestLayout = { columns: 1, rows: 1 }; + let maxMinDimension = 0; + + for (let columns = 1; columns <= camerasLength; columns++) { + const rows = Math.ceil(camerasLength / columns); + const cellWidth = containerWidth / columns; + const cellHeight = containerHeight / rows; + + // Calculate the minimum dimension (width or height) of any camera in this layout + let minDimension = Math.min(cellWidth, cellHeight); + + // Adjust for aspect ratio + Object.values(cameras).forEach((camera) => { + const aspectRatio = camera.width / camera.height; + const adjustedWidth = Math.min(cellWidth, cellHeight * aspectRatio); + const adjustedHeight = Math.min(cellHeight, cellWidth / aspectRatio); + minDimension = Math.min(minDimension, adjustedWidth, adjustedHeight); + }); + + // If this layout results in larger minimum dimensions, it's our new best layout + if (minDimension > maxMinDimension) { + maxMinDimension = minDimension; + bestLayout = { columns, rows }; + } + + // If adding more columns would make cells smaller than they need to be, stop here + if (cellWidth < minDimension) { + break; + } + } + + return bestLayout; +}; + +const useGridLayout = ( + paperRef: React.RefObject, + cameras: types.CamerasOrFailedCameras, + setPlayerItemsSize: () => void, +) => { + const theme = useTheme(); + const smBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); + const [gridLayout, setGridLayout] = useState<{ + columns: number; + rows: number; + }>({ columns: 1, rows: 1 }); + + const handleResize = useCallback(() => { + const layout = calculateLayout(paperRef, cameras, smBreakpoint); + if ( + layout.columns !== gridLayout.columns || + layout.rows !== gridLayout.rows + ) { + setGridLayout(layout); + } + setPlayerItemsSize(); + }, [ + cameras, + gridLayout.columns, + gridLayout.rows, + setPlayerItemsSize, + paperRef, + smBreakpoint, + ]); + + // Observe both the paperRef and window resize to update the layout + useResizeObserver(paperRef, handleResize); + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [handleResize]); + + return gridLayout; +}; + +const setPlayerSize = ( + paperRef: React.RefObject, + boxRef: React.RefObject, + camera: types.Camera | types.FailedCamera, + gridLayout: GridLayout, + smBreakpoint: boolean, +) => { + if (paperRef.current && boxRef.current) { + const { width, height } = calculateCellDimensions( + paperRef, + camera, + gridLayout, + smBreakpoint, + ); + boxRef.current.style.width = `${width}px`; + boxRef.current.style.height = `${height}px`; + } +}; + +interface PlayerItemRef { + setSize: () => void; +} + +type PlayerItemProps = { + camera: types.Camera | types.FailedCamera; + paperRef: React.RefObject; + requestedTimestamp: number; + gridLayout: GridLayout; +}; +const PlayerItem = forwardRef( + ({ camera, paperRef, requestedTimestamp, gridLayout }, ref) => { + const theme = useTheme(); + const smBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); + const boxRef = useRef(null); + + useImperativeHandle(ref, () => ({ + // PlayerCard will call this function to set the size of the player. + // Done this way since the player size depends on the size of the parent + // which is not known until the parent has been rendered + setSize: () => { + setPlayerSize(paperRef, boxRef, camera, gridLayout, smBreakpoint); + }, + })); + + return ( + + + + + + + ); + }, +); + +type PlayerGridProps = { + cameras: types.CamerasOrFailedCameras; + paperRef: React.RefObject; + setPlayerItemRef: (index: number) => (ref: PlayerItemRef | null) => void; + requestedTimestamp: number; + gridLayout: GridLayout; +}; +const PlayerGrid = ({ + cameras, + paperRef, + setPlayerItemRef, + requestedTimestamp, + gridLayout, +}: PlayerGridProps) => ( + + {Object.values(cameras).map((camera, index) => ( + + ))} + +); + type PlayerCardProps = { - camera: types.Camera | types.FailedCamera | null; + cameras: types.CamerasOrFailedCameras; selectedEvent: types.CameraEvent | null; requestedTimestamp: number | null; selectedTab: "events" | "timeline"; - hlsRef: React.MutableRefObject; - playerCardRef: React.RefObject; }; - export const PlayerCard = ({ - camera, + cameras, selectedEvent, requestedTimestamp, - hlsRef, - playerCardRef, }: PlayerCardProps) => { + const theme = useTheme(); + const paperRef: React.MutableRefObject = useRef(null); + const playerItemRefs = useRef<(PlayerItemRef | null)[]>([]); + const setPlayerItemRef = (index: number) => (ref: PlayerItemRef | null) => { + playerItemRefs.current[index] = ref; + }; + + const setPlayerItemsSize = useCallback(() => { + playerItemRefs.current.forEach((playerItemRef) => { + if (playerItemRef) { + playerItemRef.setSize(); + } + }); + }, []); + + const filteredCameras = useFilteredCameras(cameras); + const gridLayout = useGridLayout( + paperRef, + filteredCameras, + setPlayerItemsSize, + ); + + const camera = selectedEvent + ? cameras[selectedEvent.camera_identifier] + : null; const src = camera && selectedEvent ? getSrc(selectedEvent) : undefined; return ( - { + paperRef.current = node; + setPlayerItemsSize(); + }} variant="outlined" - sx={(theme) => ({ - marginBottom: theme.margin, + sx={{ + position: "relative", width: "100%", height: "100%", - alignItems: "center", - display: "flex", - })} + boxSizing: "content-box", + }} > - {camera && requestedTimestamp ? ( - ) : ( - - {camera && } - - + src && + camera && ( + <> + + + + ) )} - + ); }; diff --git a/frontend/src/components/events/EventTable.tsx b/frontend/src/components/events/EventTable.tsx index aabe29a27..527f4e889 100644 --- a/frontend/src/components/events/EventTable.tsx +++ b/frontend/src/components/events/EventTable.tsx @@ -9,10 +9,14 @@ import ServerDown from "svg/undraw/server_down.svg?react"; import { ErrorMessage } from "components/error/ErrorMessage"; import { EventTableItem } from "components/events/EventTableItem"; -import { getEventTimestamp, useFilterStore } from "components/events/utils"; +import { + getEventTimestamp, + useCameraStore, + useFilterStore, +} from "components/events/utils"; import { Loading } from "components/loading/Loading"; -import { useEvents } from "lib/api/events"; -import { useHlsAvailableTimespans } from "lib/api/hls"; +import { useEventsMultiple } from "lib/api/events"; +import { useHlsAvailableTimespansMultiple } from "lib/api/hls"; import { objIsEmpty, throttle } from "lib/helpers"; import * as types from "lib/types"; @@ -24,31 +28,32 @@ const groupEventsByTime = ( return []; } - const _snapshotEvents = snapshotEvents.slice().reverse(); - const groups: types.CameraEvent[][] = []; let currentGroup: types.CameraEvent[] = []; - let startOfGroup = getEventTimestamp(_snapshotEvents[0]); + let groupStartTime = getEventTimestamp(snapshotEvents[0]); + let groupCameraIdentifier = snapshotEvents[0].camera_identifier; + + for (const event of snapshotEvents) { + const currentTime = getEventTimestamp(event); - for (let i = 0; i < _snapshotEvents.length; i++) { - if (currentGroup.length === 0) { - currentGroup.push(_snapshotEvents[i]); + if ( + groupStartTime - currentTime < 120 && + event.camera_identifier === groupCameraIdentifier + ) { + currentGroup.push(event); } else { - const currentTime = getEventTimestamp(_snapshotEvents[i]); - - if (currentTime - startOfGroup < 120) { - currentGroup.push(_snapshotEvents[i]); - } else { - startOfGroup = getEventTimestamp(_snapshotEvents[i]); - groups.unshift(currentGroup); - currentGroup = [_snapshotEvents[i]]; + if (currentGroup.length > 0) { + groups.push(currentGroup); } + currentGroup = [event]; + groupStartTime = currentTime; + groupCameraIdentifier = event.camera_identifier; } } // Add the last group if it has any items if (currentGroup.length > 0) { - groups.unshift(currentGroup); + groups.push(currentGroup); } return groups; @@ -72,7 +77,7 @@ const useOnScroll = (parentRef: React.RefObject) => { type EventTableProps = { parentRef: React.RefObject; - camera: types.Camera | types.FailedCamera; + cameras: types.CamerasOrFailedCameras; date: Dayjs | null; selectedEvent: types.CameraEvent | null; setSelectedEvent: (event: types.CameraEvent) => void; @@ -82,22 +87,23 @@ type EventTableProps = { export const EventTable = memo( ({ parentRef, - camera, + cameras, date, selectedEvent, setSelectedEvent, setRequestedTimestamp, }: EventTableProps) => { const formattedDate = dayjs(date).format("YYYY-MM-DD"); - const eventsQuery = useEvents({ - camera_identifier: camera.identifier, + const { selectedCameras } = useCameraStore(); + const eventsQueries = useEventsMultiple({ + camera_identifiers: selectedCameras, date: formattedDate, utc_offset_minutes: dayjs().utcOffset(), configOptions: { enabled: !!date }, }); - const availableTimespansQuery = useHlsAvailableTimespans({ - camera_identifier: camera.identifier, + const availableTimespansQueries = useHlsAvailableTimespansMultiple({ + camera_identifiers: selectedCameras, date: formattedDate, configOptions: { enabled: !!date }, }); @@ -105,12 +111,15 @@ export const EventTable = memo( useOnScroll(parentRef); const { filters } = useFilterStore(); - if (eventsQuery.isError || availableTimespansQuery.isError) { + if (eventsQueries.isError || availableTimespansQueries.isError) { return ( @@ -119,11 +128,11 @@ export const EventTable = memo( ); } - if (eventsQuery.isLoading || availableTimespansQuery.isLoading) { + if (eventsQueries.isLoading || availableTimespansQueries.isLoading) { return ; } - if (!eventsQuery.data || objIsEmpty(eventsQuery.data)) { + if (!eventsQueries.data || objIsEmpty(eventsQueries.data)) { return ( No Events found for {formattedDate} @@ -131,34 +140,39 @@ export const EventTable = memo( ); } - const filteredEvents = eventsQuery.data.events.filter( + const filteredEvents = eventsQueries.data.filter( (event) => filters[event.type].checked, ); const groupedEvents = groupEventsByTime(filteredEvents); return ( - {groupedEvents.map((events) => ( - - - - - ))} + {groupedEvents.map((events) => { + const oldestEvent = events[events.length - 1]; + return ( + + + + + ); + })} ); diff --git a/frontend/src/components/events/EventTableItem.tsx b/frontend/src/components/events/EventTableItem.tsx index 431cccecf..6a458078f 100644 --- a/frontend/src/components/events/EventTableItem.tsx +++ b/frontend/src/components/events/EventTableItem.tsx @@ -20,12 +20,26 @@ import VideoPlayerPlaceholder from "components/videoplayer/VideoPlayerPlaceholde import { getTimeFromDate } from "lib/helpers"; import * as types from "lib/types"; -const getText = (events: types.CameraEvent[]) => { - const uniqueEvents = extractUniqueTypes(events); +const getText = ( + sortedEvents: types.CameraEvent[], + cameras: types.CamerasOrFailedCameras, +) => { + const uniqueEvents = extractUniqueTypes(sortedEvents); return ( - {`${getTimeFromDate( - new Date(getEventTime(events[0])), + + {`${ + cameras && cameras[sortedEvents[0].camera_identifier] + ? cameras[sortedEvents[0].camera_identifier].name + : sortedEvents[0].camera_identifier + }`} + + {`${getTimeFromDate( + new Date(getEventTime(sortedEvents[0])), )}`} {Object.keys(uniqueEvents).map((key) => { @@ -64,7 +78,7 @@ const isTimespanAvailable = ( }; type EventTableItemProps = { - camera: types.Camera | types.FailedCamera; + cameras: types.CamerasOrFailedCameras; events: types.CameraEvent[]; setSelectedEvent: (event: types.CameraEvent) => void; selected: boolean; @@ -72,7 +86,7 @@ type EventTableItemProps = { availableTimespans: types.HlsAvailableTimespans; }; export const EventTableItem = ({ - camera, + cameras, events, setSelectedEvent, selected, @@ -80,6 +94,10 @@ export const EventTableItem = ({ availableTimespans, }: EventTableItemProps) => { const theme = useTheme(); + // Show the oldest event first in the list, API returns latest first + const sortedEvents = events + .slice() + .sort((a, b) => a.created_at_timestamp - b.created_at_timestamp); return ( { if ( isTimespanAvailable( - Math.round(getEventTimestamp(events[0])), + Math.round(getEventTimestamp(sortedEvents[0])), availableTimespans, ) ) { - setSelectedEvent(events[0]); - setRequestedTimestamp(Math.round(getEventTimestamp(events[0]))); + setSelectedEvent(sortedEvents[0]); + setRequestedTimestamp( + Math.round(getEventTimestamp(sortedEvents[0])), + ); return; } - setSelectedEvent(events[0]); + setSelectedEvent(sortedEvents[0]); setRequestedTimestamp(null); }} > @@ -116,7 +136,7 @@ export const EventTableItem = ({ alignItems="center" > - {getText(events)} + {getText(sortedEvents, cameras)} - } + placeholder={} > , - playerCardRef: React.RefObject | undefined, -) => { - const theme = useTheme(); - const resizeObserver = useRef(); - - useEffect(() => { - if (!divRef.current || !playerCardRef || !playerCardRef.current) { - return () => {}; - } - - resizeObserver.current = new ResizeObserver(() => { - if (!divRef.current || !playerCardRef.current) { - return; - } - - divRef.current.style.maxHeight = `calc(${COLUMN_HEIGHT} - ${theme.headerHeight}px - ${theme.margin} - ${playerCardRef.current.offsetHeight}px)`; - }); - - resizeObserver.current.observe(playerCardRef.current); - - return () => { - if (resizeObserver.current) { - resizeObserver.current.disconnect(); - } - }; - }, [divRef, playerCardRef, theme.headerHeight, theme.margin]); -}; - -type CameraGridProps = { +type EventsCameraGridProps = { cameras: types.CamerasOrFailedCameras; - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; - selectedCamera: types.Camera | types.FailedCamera | null; }; -function CameraGrid({ - cameras, - changeSelectedCamera, - selectedCamera, -}: CameraGridProps) { +export function EventsCameraGrid({ cameras }: EventsCameraGridProps) { const theme = useTheme(); + const { selectedCameras, toggleCamera } = useCameraStore(); + + const handleCameraClick = ( + event: React.MouseEvent, + camera: types.Camera | types.FailedCamera, + ) => { + event.preventDefault(); + event.stopPropagation(); + toggleCamera(camera.identifier); + }; return ( @@ -74,10 +42,12 @@ function CameraGrid({ camera_identifier={camera_identifier} compact buttons={false} - onClick={changeSelectedCamera} + onClick={( + event: React.MouseEvent, + ) => handleCameraClick(event, cameras[camera_identifier])} border={ - selectedCamera && - camera_identifier === selectedCamera.identifier + selectedCameras && + selectedCameras.includes(camera_identifier) ? `2px solid ${theme.palette.primary[400]}` : "2px solid transparent" } @@ -89,70 +59,3 @@ function CameraGrid({ ); } - -type EventsCameraGridPropsCard = { - variant: "card"; - playerCardRef: React.RefObject; - cameras: types.CamerasOrFailedCameras; - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; - selectedCamera: types.Camera | types.FailedCamera | null; -}; -type EventsCameraGridPropsGrid = { - variant?: "grid"; - cameras: types.CamerasOrFailedCameras; - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; - selectedCamera: types.Camera | types.FailedCamera | null; -}; -type EventsCameraGridProps = - | EventsCameraGridPropsCard - | EventsCameraGridPropsGrid; -export function EventsCameraGrid(props: EventsCameraGridProps) { - const { - variant = "card", - cameras, - changeSelectedCamera, - selectedCamera, - } = props; - - const playerCardRef = - variant === "card" - ? (props as EventsCameraGridPropsCard).playerCardRef - : undefined; - - const ref = useRef(null); - useResizeObserver(ref, playerCardRef); - - if (variant === "grid") { - return ( - - ); - } - return ( - - - - - - ); -} diff --git a/frontend/src/components/events/FloatingMenu.tsx b/frontend/src/components/events/FloatingMenu.tsx index ba8505e28..679da9f32 100644 --- a/frontend/src/components/events/FloatingMenu.tsx +++ b/frontend/src/components/events/FloatingMenu.tsx @@ -12,25 +12,12 @@ import * as types from "lib/types"; type FloatingMenuProps = { cameras: types.CamerasOrFailedCameras; - selectedCamera: types.Camera | types.FailedCamera | null; - date: Dayjs | null; setDate: (date: Dayjs | null) => void; - - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; }; export const FloatingMenu = memo( - ({ - cameras, - selectedCamera, - date, - setDate, - changeSelectedCamera, - }: FloatingMenuProps) => { + ({ cameras, date, setDate }: FloatingMenuProps) => { const [cameraDialogOpen, setCameraDialogOpen] = useState(false); const [dateDialogOpen, setDateDialogOpen] = useState(false); @@ -40,14 +27,11 @@ export const FloatingMenu = memo( open={cameraDialogOpen} setOpen={setCameraDialogOpen} cameras={cameras} - changeSelectedCamera={changeSelectedCamera} - selectedCamera={selectedCamera} /> { setDateDialogOpen(false); setDate(value); diff --git a/frontend/src/components/events/Layouts.tsx b/frontend/src/components/events/Layouts.tsx index 09d5c21e9..4ceb1c356 100644 --- a/frontend/src/components/events/Layouts.tsx +++ b/frontend/src/components/events/Layouts.tsx @@ -2,123 +2,135 @@ import TabContext from "@mui/lab/TabContext"; import TabList from "@mui/lab/TabList"; import TabPanel from "@mui/lab/TabPanel"; import Box from "@mui/material/Box"; -import Card from "@mui/material/Card"; -import CardContent from "@mui/material/CardContent"; import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; import Tab from "@mui/material/Tab"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import { Dayjs } from "dayjs"; -import Hls from "hls.js"; -import { SyntheticEvent, memo, useEffect, useRef } from "react"; +import { SyntheticEvent, memo, useCallback, useEffect, useRef } from "react"; import { PlayerCard } from "components/events/EventPlayerCard"; import { EventTable } from "components/events/EventTable"; import { FilterMenu } from "components/events/FilterMenu"; import { FloatingMenu } from "components/events/FloatingMenu"; import { TimelineTable } from "components/events/timeline/TimelineTable"; -import { COLUMN_HEIGHT, COLUMN_HEIGHT_SMALL } from "components/events/utils"; +import { + COLUMN_HEIGHT, + COLUMN_HEIGHT_SMALL, + playerCardSmMaxHeight, + useCameraStore, +} from "components/events/utils"; +import { useResizeObserver } from "hooks/UseResizeObserver"; import { insertURLParameter } from "lib/helpers"; import * as types from "lib/types"; -const useSetTableHeight = ( - cardRef: React.RefObject, +const setTableHeight = ( tabListRef: React.RefObject, eventsRef: React.RefObject, timelineRef: React.RefObject, + playerCardGridItemRef: React.MutableRefObject, + theme: any, + smBreakpoint: boolean, ) => { - const resizeObserver = useRef(); - useEffect(() => { - if (cardRef.current) { - resizeObserver.current = new ResizeObserver(() => { - if ( - !cardRef.current || - !tabListRef.current || - !eventsRef.current || - !timelineRef.current - ) { - return; - } - timelineRef.current.style.height = `calc(${cardRef.current.clientHeight}px - ${tabListRef.current.clientHeight}px)`; - eventsRef.current.style.height = timelineRef.current.style.height; - }); - resizeObserver.current.observe(cardRef.current); + if ( + tabListRef.current && + eventsRef.current && + timelineRef.current && + playerCardGridItemRef.current + ) { + if (smBreakpoint) { + eventsRef.current.style.height = `calc(${COLUMN_HEIGHT} - ${theme.headerHeight}px - ${tabListRef.current.offsetHeight}px)`; + timelineRef.current.style.height = eventsRef.current.style.height; + } else { + eventsRef.current.style.height = `calc(${COLUMN_HEIGHT_SMALL} - ${theme.headerHeight}px - ${tabListRef.current.offsetHeight}px - ${playerCardGridItemRef.current.offsetHeight}px)`; + timelineRef.current.style.height = eventsRef.current.style.height; } - return () => { - if (resizeObserver.current) { - resizeObserver.current.disconnect(); - } - }; - }, [cardRef, eventsRef, tabListRef, timelineRef]); + } }; -const useSetCardHeight = ( - gridRef: React.RefObject, - cardRef: React.RefObject, - playerCardRef: React.RefObject, - smBreakpoint: boolean, +const useSetTableHeight = ( + tabListRef: React.RefObject, + eventsRef: React.RefObject, + timelineRef: React.RefObject, + playerCardGridItemRef: React.MutableRefObject, +) => { + const theme = useTheme(); + const smBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); + + const _setTableHeight = useCallback(() => { + setTableHeight( + tabListRef, + eventsRef, + timelineRef, + playerCardGridItemRef, + theme, + smBreakpoint, + ); + }, [ + tabListRef, + eventsRef, + timelineRef, + playerCardGridItemRef, + theme, + smBreakpoint, + ]); + + useResizeObserver(playerCardGridItemRef, _setTableHeight); + _setTableHeight(); +}; + +const useSetPlayerCardHeight = ( + playerCardGridItemRef: React.MutableRefObject, ) => { const theme = useTheme(); - const resizeObserver = useRef(); + const smBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); + useEffect(() => { - if (playerCardRef.current) { - resizeObserver.current = new ResizeObserver(() => { - if ( - !smBreakpoint && - cardRef.current && - playerCardRef.current && - gridRef.current - ) { - cardRef.current.style.height = `calc(${COLUMN_HEIGHT_SMALL} - ${ - theme.headerHeight - }px - ${playerCardRef.current!.clientHeight}px)`; - cardRef.current.style.maxHeight = "unset"; - gridRef.current.style.height = "unset"; - gridRef.current.style.maxHeight = "unset"; - } else if (smBreakpoint && cardRef.current && gridRef.current) { - cardRef.current.style.height = `calc(${COLUMN_HEIGHT} - ${theme.headerHeight}px)`; - cardRef.current.style.maxHeight = cardRef.current.style.height; - gridRef.current.style.height = cardRef.current.style.height; - gridRef.current.style.maxHeight = cardRef.current.style.maxHeight; + const handleResize = () => { + if (playerCardGridItemRef.current) { + if (smBreakpoint) { + playerCardGridItemRef.current.style.height = `calc(${COLUMN_HEIGHT} - ${theme.headerHeight}px)`; + playerCardGridItemRef.current.style.maxHeight = "unset"; + } else { + playerCardGridItemRef.current.style.height = "100%"; + playerCardGridItemRef.current.style.maxHeight = `${playerCardSmMaxHeight()}px`; } - }); - resizeObserver.current.observe(playerCardRef.current); - } - return () => { - if (resizeObserver.current) { - resizeObserver.current.disconnect(); } }; - }, [cardRef, gridRef, playerCardRef, smBreakpoint, theme.headerHeight]); + + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [playerCardGridItemRef, smBreakpoint, theme.headerHeight]); }; type TabsProps = { - hlsRef: React.MutableRefObject; + cameras: types.CamerasOrFailedCameras; date: Dayjs | null; selectedTab: "events" | "timeline"; setSelectedTab: (tab: "events" | "timeline") => void; - selectedCamera: types.Camera | types.FailedCamera | null; selectedEvent: types.CameraEvent | null; setSelectedEvent: (event: types.CameraEvent) => void; setRequestedTimestamp: (timestamp: number | null) => void; - cardRef: React.RefObject; + playerCardGridItemRef: React.MutableRefObject; }; const Tabs = ({ - hlsRef, + cameras, date, selectedTab, setSelectedTab, - selectedCamera, selectedEvent, setSelectedEvent, setRequestedTimestamp, - cardRef, + playerCardGridItemRef, }: TabsProps) => { + const { selectedCameras } = useCameraStore(); const tabListRef = useRef(null); const eventsRef = useRef(null); const timelineRef = useRef(null); - useSetTableHeight(cardRef, tabListRef, eventsRef, timelineRef); + useSetTableHeight(tabListRef, eventsRef, timelineRef, playerCardGridItemRef); const handleTabChange = ( event: SyntheticEvent, @@ -169,12 +181,13 @@ const Tabs = ({ paddingTop: "5px", overflow: "auto", overflowX: "hidden", + boxSizing: "border-box", }} > - {selectedCamera ? ( + {selectedCameras.length > 0 ? ( ) : ( - Select a camera to load Events + Select at least one camera to load Events )} @@ -195,21 +208,20 @@ const Tabs = ({ paddingBottom: "50px", overflow: "auto", overflowX: "hidden", + boxSizing: "border-box", }} > - {selectedCamera ? ( + {selectedCameras.length > 0 ? ( ) : ( - Select a camera to load Timeline + Select at least one camera to load Timeline )} @@ -219,13 +231,8 @@ const Tabs = ({ type LayoutProps = { cameras: types.CamerasOrFailedCameras; - selectedCamera: types.Camera | types.FailedCamera | null; selectedEvent: types.CameraEvent | null; setSelectedEvent: (event: types.CameraEvent) => void; - changeSelectedCamera: ( - ev: React.MouseEvent, - camera: types.Camera | types.FailedCamera, - ) => void; date: Dayjs | null; setDate: (date: Dayjs | null) => void; requestedTimestamp: number | null; @@ -237,10 +244,8 @@ type LayoutProps = { export const Layout = memo( ({ cameras, - selectedCamera, selectedEvent, setSelectedEvent, - changeSelectedCamera, date, setDate, requestedTimestamp, @@ -250,11 +255,8 @@ export const Layout = memo( }: LayoutProps) => { const theme = useTheme(); const smBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); - const hlsRef = useRef(null); - const cardRef = useRef(null); - const playerCardRef = useRef(null); - const gridRef = useRef(null); - useSetCardHeight(gridRef, cardRef, playerCardRef, smBreakpoint); + const playerCardGridItemRef = useRef(null); + useSetPlayerCardHeight(playerCardGridItemRef); return ( @@ -264,40 +266,47 @@ export const Layout = memo( rowSpacing={{ xs: 0.5, sm: 0 }} columnSpacing={1} > - + - - - - - - + + + + - + ); diff --git a/frontend/src/components/events/SnapshotEvent.tsx b/frontend/src/components/events/SnapshotEvent.tsx index 7fd7837de..b1f5e2341 100644 --- a/frontend/src/components/events/SnapshotEvent.tsx +++ b/frontend/src/components/events/SnapshotEvent.tsx @@ -201,7 +201,11 @@ export const SnapshotIcon = ({ events }: { events: types.CameraEvent[] }) => { }; const SnapshotIcons = ({ events }: { events: types.CameraEvent[] }) => { - const uniqueEvents = extractUniqueTypes(events); + // Show the oldest event first in the list, API returns latest first + const sortedEvents = events + .slice() + .sort((a, b) => a.created_at_timestamp - b.created_at_timestamp); + const uniqueEvents = extractUniqueTypes(sortedEvents); return ( {Object.keys(uniqueEvents).map((key) => { diff --git a/frontend/src/components/events/timeline/ProgressLine.tsx b/frontend/src/components/events/timeline/ProgressLine.tsx index 75b3eb3e6..d38636bc4 100644 --- a/frontend/src/components/events/timeline/ProgressLine.tsx +++ b/frontend/src/components/events/timeline/ProgressLine.tsx @@ -3,11 +3,15 @@ import DOMPurify from "dompurify"; import Hls from "hls.js"; import { memo, useEffect, useRef } from "react"; -import { TICK_HEIGHT, getYPosition } from "components/events/utils"; +import { + TICK_HEIGHT, + getYPosition, + useHlsStore, +} from "components/events/utils"; import { dateToTimestamp, getTimeFromDate } from "lib/helpers"; const useTimeUpdate = ( - hlsRef: React.MutableRefObject, + hlsRef: React.MutableRefObject | undefined, containerRef: React.MutableRefObject, startRef: React.MutableRefObject, endRef: React.MutableRefObject, @@ -15,8 +19,13 @@ const useTimeUpdate = ( timeRef: React.MutableRefObject, ) => { useEffect(() => { + if (!hlsRef || !hlsRef.current) { + return () => {}; + } + const hls = hlsRef.current; + const onTimeUpdate = () => { - if (!hlsRef.current) { + if (!hlsRef || !hlsRef.current) { return; } const currentTime = hlsRef.current.media?.currentTime; @@ -52,13 +61,12 @@ const useTimeUpdate = ( }; const interval = setInterval(() => { - if (hlsRef.current) { + if (hlsRef && hlsRef.current) { hlsRef.current.media?.addEventListener("timeupdate", onTimeUpdate); clearInterval(interval); } }, 1000); - const hls = hlsRef.current; return () => { if (hls) { hls.media?.removeEventListener("timeupdate", onTimeUpdate); @@ -92,14 +100,15 @@ const useWidthObserver = ( type ProgressLineProps = { containerRef: React.MutableRefObject; - hlsRef: React.MutableRefObject; startRef: React.MutableRefObject; endRef: React.MutableRefObject; }; export const ProgressLine = memo( - ({ containerRef, hlsRef, startRef, endRef }: ProgressLineProps) => { + ({ containerRef, startRef, endRef }: ProgressLineProps) => { const ref = useRef(null); const timeRef = useRef(null); + const { hlsRefs } = useHlsStore(); + const hlsRef = hlsRefs[0]; useTimeUpdate(hlsRef, containerRef, startRef, endRef, ref, timeRef); useWidthObserver(containerRef, ref); diff --git a/frontend/src/components/events/timeline/TimelinePlayer.tsx b/frontend/src/components/events/timeline/TimelinePlayer.tsx index 427a60e93..aba75af1e 100644 --- a/frontend/src/components/events/timeline/TimelinePlayer.tsx +++ b/frontend/src/components/events/timeline/TimelinePlayer.tsx @@ -1,4 +1,3 @@ -import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -6,11 +5,10 @@ import Hls, { LevelLoadedData } from "hls.js"; import React, { useEffect, useRef } from "react"; import { v4 as uuidv4 } from "uuid"; -import { CameraNameOverlay } from "components/camera/CameraNameOverlay"; import { SCALE, - calculateHeight, findFragmentByTimestamp, + useHlsStore, } from "components/events/utils"; import { useAuthContext } from "context/AuthContext"; import { useFirstRender } from "hooks/UseFirstRender"; @@ -156,7 +154,6 @@ const initializePlayer = ( // Handle errors hlsRef.current.on(Hls.Events.ERROR, (_event, data) => { - console.error("HLS error", data.details, data.type, data.fatal); if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: @@ -190,9 +187,11 @@ const useInitializePlayer = ( camera: types.Camera | types.FailedCamera, ) => { const { auth } = useAuthContext(); + const { addHlsRef } = useHlsStore(); useEffect(() => { if (Hls.isSupported()) { + addHlsRef(hlsRef); initializePlayer( hlsRef, hlsClientIdRef, @@ -284,50 +283,17 @@ const useSeekToTimestamp = ( ]); }; -const useResizeObserver = ( - containerRef: React.RefObject, - videoRef: React.RefObject, - camera: types.Camera | types.FailedCamera, -) => { - const resizeObserver = useRef(); - - useEffect(() => { - if (containerRef.current) { - resizeObserver.current = new ResizeObserver(() => { - if (!videoRef.current || !containerRef.current) { - return; - } - - videoRef.current.style.height = `${calculateHeight( - camera.width, - camera.height, - containerRef.current.offsetWidth, - )}px`; - videoRef.current.style.maxHeight = `${containerRef.current.offsetHeight}px`; - }); - resizeObserver.current.observe(containerRef.current); - } - return () => { - if (resizeObserver.current) { - resizeObserver.current.disconnect(); - } - }; - }, [camera, containerRef, videoRef]); -}; interface TimelinePlayerProps { - containerRef: React.RefObject; - hlsRef: React.MutableRefObject; camera: types.Camera | types.FailedCamera; requestedTimestamp: number; } export const TimelinePlayer: React.FC = ({ - containerRef, - hlsRef, camera, requestedTimestamp, }) => { const theme = useTheme(); + const hlsRef = useRef(null); const videoRef = useRef(null); const hlsClientIdRef = useRef(uuidv4()); const initialProgramDateTime = useRef(null); @@ -349,40 +315,19 @@ export const TimelinePlayer: React.FC = ({ requestedTimestamp, camera, ); - useResizeObserver(containerRef, videoRef, camera); return ( - - - - - + controls + playsInline + /> ); }; diff --git a/frontend/src/components/events/timeline/TimelineTable.tsx b/frontend/src/components/events/timeline/TimelineTable.tsx index 133917249..3ee99e383 100644 --- a/frontend/src/components/events/timeline/TimelineTable.tsx +++ b/frontend/src/components/events/timeline/TimelineTable.tsx @@ -1,5 +1,4 @@ import dayjs, { Dayjs } from "dayjs"; -import Hls from "hls.js"; import { memo, useContext, useEffect, useMemo, useRef, useState } from "react"; import ServerDown from "svg/undraw/server_down.svg?react"; @@ -14,14 +13,14 @@ import { calculateStart, getDateAtPosition, getTimelineItems, + useCameraStore, useFilterStore, } from "components/events/utils"; import { Loading } from "components/loading/Loading"; import { ViseronContext } from "context/ViseronContext"; -import { useEvents } from "lib/api/events"; -import { useHlsAvailableTimespans } from "lib/api/hls"; +import { useEventsMultiple } from "lib/api/events"; +import { useHlsAvailableTimespansMultiple } from "lib/api/hls"; import { dateToTimestamp } from "lib/helpers"; -import * as types from "lib/types"; // Move startRef.current forward every SCALE seconds const useAddTicks = ( @@ -83,26 +82,17 @@ const timelineClick = ( if (timestamp > dayjs().unix()) { return; } - // Position the line and display the time setRequestedTimestamp(timestamp); }; type TimelineTableProps = { parentRef: React.MutableRefObject; - hlsRef: React.MutableRefObject; - camera: types.Camera | types.FailedCamera; date: Dayjs | null; setRequestedTimestamp: (timestamp: number | null) => void; }; export const TimelineTable = memo( - ({ - parentRef, - hlsRef, - camera, - date, - setRequestedTimestamp, - }: TimelineTableProps) => { + ({ parentRef, date, setRequestedTimestamp }: TimelineTableProps) => { const containerRef = useRef(null); const startRef = useRef(calculateStart(date)); const endRef = useRef(calculateEnd(date)); @@ -114,16 +104,17 @@ export const TimelineTable = memo( useAddTicks(date, startRef, setStart); startRef.current = start; - const eventsQuery = useEvents({ - camera_identifier: camera.identifier, + const { selectedCameras } = useCameraStore(); + const eventsQueries = useEventsMultiple({ + camera_identifiers: selectedCameras, time_from: endRef.current, time_to: startRef.current, configOptions: { keepPreviousData: true, }, }); - const availableTimespansQuery = useHlsAvailableTimespans({ - camera_identifier: camera.identifier, + const availableTimespansQueries = useHlsAvailableTimespansMultiple({ + camera_identifiers: selectedCameras, time_from: endRef.current, time_to: startRef.current, configOptions: { @@ -136,18 +127,18 @@ export const TimelineTable = memo( () => getTimelineItems( startRef, - eventsQuery.data?.events || [], - availableTimespansQuery.data?.timespans || [], + eventsQueries.data || [], + availableTimespansQueries.data?.timespans || [], filters, ), - [eventsQuery.data, availableTimespansQuery.data, filters], + [eventsQueries.data, availableTimespansQueries.data?.timespans, filters], ); - if (eventsQuery.error || availableTimespansQuery.error) { - const subtext = eventsQuery.error - ? eventsQuery.error.message - : availableTimespansQuery.error - ? availableTimespansQuery.error.message + if (eventsQueries.error || availableTimespansQueries.error) { + const subtext = eventsQueries.error + ? eventsQueries.error.message + : availableTimespansQueries.error + ? availableTimespansQueries.error.message : "Unknown error"; return ( ); } - if ( - eventsQuery.isInitialLoading || - availableTimespansQuery.isInitialLoading - ) { + if (eventsQueries.isInitialLoading || eventsQueries.isInitialLoading) { return ; } @@ -184,7 +172,6 @@ export const TimelineTable = memo( containerRef={containerRef} startRef={startRef} endRef={endRef} - hlsRef={hlsRef} /> window.innerHeight * 0.4; + type Filters = { [key in types.CameraEvent["type"]]: { label: string; @@ -60,6 +63,99 @@ export const useFilterStore = create()( ), ); +type Cameras = { + [key: string]: boolean; +}; + +interface CameraState { + cameras: Cameras; + selectedCameras: string[]; + toggleCamera: (cameraIdentifier: string) => void; + selectSingleCamera: (cameraIdentifier: string) => void; + selectionOrder: string[]; +} + +export const useCameraStore = create()( + persist( + (set) => ({ + cameras: {}, + selectedCameras: [], + toggleCamera: (cameraIdentifier) => { + set((state) => { + const newCameras = { ...state.cameras }; + newCameras[cameraIdentifier] = !newCameras[cameraIdentifier]; + let newSelectionOrder = [...state.selectionOrder]; + if (newCameras[cameraIdentifier]) { + newSelectionOrder.push(cameraIdentifier); + } else { + newSelectionOrder = newSelectionOrder.filter( + (id) => id !== cameraIdentifier, + ); + } + return { + cameras: newCameras, + selectedCameras: Object.entries(newCameras) + .filter(([_key, value]) => value) + .map(([key]) => key), + selectionOrder: newSelectionOrder, + }; + }); + }, + selectSingleCamera: (cameraIdentifier) => { + set((state) => { + const newCameras = { ...state.cameras }; + Object.keys(newCameras).forEach((key) => { + newCameras[key] = key === cameraIdentifier; + }); + return { + cameras: newCameras, + selectedCameras: [cameraIdentifier], + selectionOrder: [cameraIdentifier], + }; + }); + }, + selectionOrder: [], + }), + { name: "camera-store" }, + ), +); + +export const useFilteredCameras = (cameras: types.CamerasOrFailedCameras) => { + const { selectedCameras } = useCameraStore(); + return useMemo( + () => + Object.keys(cameras) + .filter((key) => selectedCameras.includes(key)) + .reduce((obj: types.CamerasOrFailedCameras, key) => { + obj[key] = cameras[key]; + return obj; + }, {}), + [cameras, selectedCameras], + ); +}; + +interface HlsStore { + hlsRefs: React.MutableRefObject[]; + addHlsRef: (hlsRef: React.MutableRefObject) => void; + removeHlsRef: (hlsRef: React.MutableRefObject) => void; +} + +export const useHlsStore = create((set) => ({ + hlsRefs: [], + // add a new Hls ref to the store only if it does not exist + addHlsRef: (hlsRef: React.MutableRefObject) => + set((state) => { + if (!state.hlsRefs.includes(hlsRef)) { + return { hlsRefs: [...state.hlsRefs, hlsRef] }; + } + return state; + }), + removeHlsRef: (hlsRef: React.MutableRefObject) => + set((state) => ({ + hlsRefs: state.hlsRefs.filter((ref) => ref !== hlsRef), + })), +})); + export const DEFAULT_ITEM: TimelineItem = { time: 0, timedEvent: null, @@ -366,6 +462,12 @@ export const calculateHeight = ( width: number, ): number => (width * cameraHeight) / cameraWidth; +export const calculateWidth = ( + cameraWidth: number, + cameraHeight: number, + height: number, +): number => (height * cameraWidth) / cameraHeight; + export const getSrc = (event: types.CameraEvent) => { switch (event.type) { case "recording": diff --git a/frontend/src/components/footer/Footer.tsx b/frontend/src/components/footer/Footer.tsx index 4dc63d1a6..69eb96e71 100644 --- a/frontend/src/components/footer/Footer.tsx +++ b/frontend/src/components/footer/Footer.tsx @@ -2,7 +2,7 @@ import GitHubIcon from "@mui/icons-material/GitHub"; import Link from "@mui/material/Link"; import Typography from "@mui/material/Typography"; import { styled, useTheme } from "@mui/material/styles"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import { useLocation } from "react-router-dom"; import { ViseronContext } from "context/ViseronContext"; @@ -17,17 +17,9 @@ const Footer = styled("footer")(() => ({ export default function AppFooter() { const theme = useTheme(); - const [showFooter, setShowFooter] = useState(true); const location = useLocation(); const { version, gitCommit } = useContext(ViseronContext); - - useEffect(() => { - if (["/configuration", "/events"].includes(location.pathname)) { - setShowFooter(false); - return; - } - setShowFooter(true); - }, [location]); + const showFooter = !["/configuration", "/events"].includes(location.pathname); return showFooter ? (