diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx index 21883ccdc..6450626d4 100644 --- a/web/src/components/icons/IconPicker.tsx +++ b/web/src/components/icons/IconPicker.tsx @@ -95,10 +95,11 @@ export default function IconPicker({ align="start" side="top" container={containerRef.current} - className="max-h-[50dvh]" + className="flex flex-col max-h-[50dvh] md:max-h-[30dvh] overflow-y-hidden" >
Select an icon + setSearchTerm(e.target.value)} /> -
-
+
+
{icons.map(([name, Icon]) => (
{ handleIconSelect({ name, Icon }); setOpen(false); diff --git a/web/src/hooks/use-fullscreen.ts b/web/src/hooks/use-fullscreen.ts new file mode 100644 index 000000000..0c5d28fbf --- /dev/null +++ b/web/src/hooks/use-fullscreen.ts @@ -0,0 +1,146 @@ +import { RefObject, useCallback, useEffect, useState } from "react"; + +function getFullscreenElement(): HTMLElement | null { + return ( + document.fullscreenElement || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (document as any).webkitFullscreenElement || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (document as any).mozFullScreenElement || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (document as any).msFullscreenElement + ); +} + +function exitFullscreen(): Promise | null { + if (document.exitFullscreen) return document.exitFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((document as any).msExitFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (document as any).msExitFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((document as any).webkitExitFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (document as any).webkitExitFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((document as any).mozCancelFullScreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (document as any).mozCancelFullScreen(); + return null; +} + +function enterFullScreen(element: HTMLElement): Promise | null { + if (element.requestFullscreen) return element.requestFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any).msRequestFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (element as any).msRequestFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any).webkitEnterFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (element as any).webkitEnterFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any).webkitRequestFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (element as any).webkitRequestFullscreen(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any).mozRequestFullscreen) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (element as any).mozRequestFullscreen(); + return null; +} + +const prefixes = ["", "webkit", "moz", "ms"]; + +function addEventListeners( + element: HTMLElement, + onFullScreen: (event: Event) => void, + onError: (event: Event) => void, +) { + prefixes.forEach((prefix) => { + element.addEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.addEventListener(`${prefix}fullscreenerror`, onError); + }); +} + +function removeEventListeners( + element: HTMLElement, + onFullScreen: (event: Event) => void, + onError: (event: Event) => void, +) { + prefixes.forEach((prefix) => { + element.removeEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.removeEventListener(`${prefix}fullscreenerror`, onError); + }); +} + +export function useFullscreen( + elementRef: RefObject, +) { + const [fullscreen, setFullscreen] = useState(false); + const [error, setError] = useState(null); + + const handleFullscreenChange = useCallback((event: Event) => { + setFullscreen(event.target === getFullscreenElement()); + }, []); + + const handleFullscreenError = useCallback((event: Event) => { + setFullscreen(false); + setError( + new Error( + `Error attempting full-screen mode: ${event} (${event.target})`, + ), + ); + }, []); + + const toggleFullscreen = useCallback(async () => { + try { + if (!getFullscreenElement()) { + await enterFullScreen(elementRef.current!); + } else { + await exitFullscreen(); + } + setError(null); + } catch (err) { + setError(err as Error); + } + }, [elementRef]); + + const clearError = useCallback(() => { + setError(null); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "F11") { + toggleFullscreen(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [toggleFullscreen]); + + useEffect(() => { + const currentElement = elementRef.current; + if (currentElement) { + addEventListeners( + currentElement, + handleFullscreenChange, + handleFullscreenError, + ); + return () => { + removeEventListeners( + currentElement, + handleFullscreenChange, + handleFullscreenError, + ); + }; + } + }, [elementRef, handleFullscreenChange, handleFullscreenError]); + + return { fullscreen, toggleFullscreen, error, clearError }; +} diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 891c41bd0..80f0716e6 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -12,7 +12,12 @@ import React, { useRef, useState, } from "react"; -import { Layout, Responsive, WidthProvider } from "react-grid-layout"; +import { + ItemCallback, + Layout, + Responsive, + WidthProvider, +} from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import { LivePlayerMode } from "@/types/live"; @@ -30,12 +35,14 @@ import { cn } from "@/lib/utils"; import { EditGroupDialog } from "@/components/filter/CameraGroupSelector"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { FaCompress, FaExpand } from "react-icons/fa"; -import { Button } from "@/components/ui/button"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { useFullscreen } from "@/hooks/use-fullscreen"; +import { toast } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; @@ -271,22 +278,17 @@ export default function DraggableGridLayout({ // fullscreen state + const { fullscreen, toggleFullscreen, error, clearError } = + useFullscreen(gridContainerRef); + useEffect(() => { - if (gridContainerRef.current == null) { - return; + if (error !== null) { + toast.error(`Error attempting fullscreen mode: ${error}`, { + position: "top-center", + }); + clearError(); } - - const listener = () => { - setFullscreen(document.fullscreenElement != null); - }; - document.addEventListener("fullscreenchange", listener); - - return () => { - document.removeEventListener("fullscreenchange", listener); - }; - }, [gridContainerRef]); - - const [fullscreen, setFullscreen] = useState(false); + }, [error, clearError]); const cellHeight = useMemo(() => { const aspectRatio = 16 / 9; @@ -301,8 +303,27 @@ export default function DraggableGridLayout({ ); }, [containerWidth, marginValue]); + const handleResize: ItemCallback = ( + _: Layout[], + oldLayoutItem: Layout, + layoutItem: Layout, + placeholder: Layout, + ) => { + const heightDiff = layoutItem.h - oldLayoutItem.h; + const widthDiff = layoutItem.w - oldLayoutItem.w; + const changeCoef = oldLayoutItem.w / oldLayoutItem.h; + if (Math.abs(heightDiff) < Math.abs(widthDiff)) { + layoutItem.h = layoutItem.w / changeCoef; + placeholder.h = layoutItem.w / changeCoef; + } else { + layoutItem.w = layoutItem.h * changeCoef; + placeholder.w = layoutItem.h * changeCoef; + } + }; + return ( <> + {!isGridLayoutLoaded || !currentGridLayout ? (
{includeBirdseye && birdseyeConfig?.enabled && ( @@ -344,6 +365,7 @@ export default function DraggableGridLayout({ containerPadding={[0, isEditMode ? 6 : 3]} resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []} onDragStop={handleLayoutChange} + onResize={handleResize} onResizeStop={handleLayoutChange} > {includeBirdseye && birdseyeConfig?.enabled && ( @@ -394,7 +416,7 @@ export default function DraggableGridLayout({ ); })} - {isDesktop && !fullscreen && ( + {isDesktop && (
- +
{isEditMode ? "Exit Editing" : "Edit Layout"} @@ -431,14 +449,14 @@ export default function DraggableGridLayout({ <> - + +
{isEditMode ? "Exit Editing" : "Edit Camera Group"} @@ -446,26 +464,16 @@ export default function DraggableGridLayout({ - +
{fullscreen ? "Exit Fullscreen" : "Fullscreen"} diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 3a9616195..c109bb977 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -22,7 +22,8 @@ import { import useSWR from "swr"; import DraggableGridLayout from "./DraggableGridLayout"; import { IoClose } from "react-icons/io5"; -import { LuMove } from "react-icons/lu"; +import { LuLayoutDashboard } from "react-icons/lu"; +import { cn } from "@/lib/utils"; type LiveDashboardViewProps = { cameras: CameraConfig[]; @@ -190,13 +191,18 @@ export default function LiveDashboardView({ {cameraGroup && cameraGroup !== "default" && isTablet && (
)}