From 20c060ed8f1c17699f50abf9f13b7847a60255ed Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:51:34 +1100 Subject: [PATCH] Revert "Add per-camera dashboard rotation and cover-fit support for live views" --- frigate/config/camera/ui.py | 5 - .../camera/AutoUpdatingCameraImage.tsx | 3 - web/src/components/camera/CameraImage.tsx | 18 +-- web/src/components/player/JSMpegPlayer.tsx | 52 +++------ web/src/components/player/LivePlayer.test.tsx | 104 ------------------ web/src/components/player/LivePlayer.tsx | 76 ++----------- web/src/types/frigateConfig.ts | 1 - web/src/views/live/DraggableGridLayout.tsx | 57 +++++++--- web/src/views/live/LiveDashboardView.tsx | 16 ++- 9 files changed, 83 insertions(+), 249 deletions(-) delete mode 100644 web/src/components/player/LivePlayer.test.tsx diff --git a/frigate/config/camera/ui.py b/frigate/config/camera/ui.py index 01f715f58..5e903b254 100644 --- a/frigate/config/camera/ui.py +++ b/frigate/config/camera/ui.py @@ -16,8 +16,3 @@ class CameraUiConfig(FrigateBaseModel): title="Show in UI", description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", ) - rotate: bool = Field( - default=False, - title="Rotate in grid", - description="Rotate this camera 90 degrees clockwise in multi-camera dashboard/grid views only.", - ) diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index 1a5b9b033..95d90d9bd 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -9,7 +9,6 @@ type AutoUpdatingCameraImageProps = { cameraClasses?: string; reloadInterval?: number; periodicCache?: boolean; - fit?: "contain" | "cover"; }; const MIN_LOAD_TIMEOUT_MS = 200; @@ -22,7 +21,6 @@ export default function AutoUpdatingCameraImage({ cameraClasses, reloadInterval = MIN_LOAD_TIMEOUT_MS, periodicCache = false, - fit = "contain", }: AutoUpdatingCameraImageProps) { const [key, setKey] = useState(Date.now()); const [fps, setFps] = useState("0"); @@ -98,7 +96,6 @@ export default function AutoUpdatingCameraImage({ onload={handleLoad} searchParams={cacheKey} className={cameraClasses} - fit={fit} /> {showFps ? Displaying at {fps}fps : null} diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index b3de66d64..f0c05995e 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -12,7 +12,6 @@ type CameraImageProps = { camera: string; onload?: () => void; searchParams?: string; - fit?: "contain" | "cover"; }; export default function CameraImage({ @@ -20,7 +19,6 @@ export default function CameraImage({ camera, onload, searchParams = "", - fit = "contain", }: CameraImageProps) { const { data: config } = useSWR("config"); const apiHost = useApiHost(); @@ -89,16 +87,12 @@ export default function CameraImage({ void; onPlaying?: () => void; - fit?: "contain" | "cover"; }; export default function JSMpegPlayer({ @@ -29,7 +28,6 @@ export default function JSMpegPlayer({ useWebGL = false, setStats, onPlaying, - fit = "contain", }: JSMpegPlayerProps) { const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const videoRef = useRef(null); @@ -62,28 +60,8 @@ export default function JSMpegPlayer({ [containerWidth, containerHeight], ); - const scaledDimensions = useMemo(() => { - if (!width || !height || !containerWidth || !containerHeight) { - return { width: undefined, height: undefined }; - } - - if (fit == "cover") { - if (aspectRatio < fitAspect) { - const coverWidth = Math.ceil(containerWidth); - return { - width: coverWidth, - height: Math.ceil(coverWidth / aspectRatio), - }; - } - - const coverHeight = Math.ceil(containerHeight); - return { - width: Math.ceil(coverHeight * aspectRatio), - height: coverHeight, - }; - } - - if (selectedContainerRef?.current) { + const scaledHeight = useMemo(() => { + if (selectedContainerRef?.current && width && height) { const scaledHeight = aspectRatio < (fitAspect ?? 0) ? Math.floor( @@ -100,31 +78,33 @@ export default function JSMpegPlayer({ : Math.min(scaledHeight, height); if (finalHeight > 0) { - return { - width: Math.ceil(finalHeight * aspectRatio), - height: finalHeight, - }; + return finalHeight; } } - - return { width: undefined, height: undefined }; + return undefined; }, [ aspectRatio, containerWidth, containerHeight, fitAspect, - fit, height, width, stretch, selectedContainerRef, ]); + const scaledWidth = useMemo(() => { + if (aspectRatio && scaledHeight) { + return Math.ceil(scaledHeight * aspectRatio); + } + return undefined; + }, [scaledHeight, aspectRatio]); + useEffect(() => { - if (scaledDimensions.width && scaledDimensions.height) { + if (scaledWidth && scaledHeight) { setDimensionsReady(true); } - }, [scaledDimensions]); + }, [scaledWidth, scaledHeight]); useEffect(() => { onPlayingRef.current = onPlaying; @@ -262,7 +242,7 @@ export default function JSMpegPlayer({
@@ -270,8 +250,8 @@ export default function JSMpegPlayer({ ref={canvasRef} className="rounded-lg md:rounded-2xl" style={{ - width: scaledDimensions.width, - height: scaledDimensions.height, + width: scaledWidth, + height: scaledHeight, }} >
diff --git a/web/src/components/player/LivePlayer.test.tsx b/web/src/components/player/LivePlayer.test.tsx deleted file mode 100644 index 60dafa210..000000000 --- a/web/src/components/player/LivePlayer.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; - -import LivePlayer from "./LivePlayer"; -import { CameraConfig } from "@/types/frigateConfig"; - -vi.mock("@/hooks/resize-observer", () => ({ - useResizeObserver: () => [{ width: 300, height: 200 }], -})); - -vi.mock("@/hooks/use-camera-activity", () => ({ - useCameraActivity: () => ({ - enabled: true, - activeMotion: true, - activeTracking: false, - objects: [], - offline: false, - }), -})); - -vi.mock("@/hooks/use-camera-friendly-name", () => ({ - useCameraFriendlyName: () => "Front Door", -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), - Trans: ({ children }: { children: string }) => children, - initReactI18next: { type: "3rdParty", init: () => undefined }, -})); - -vi.mock("@/utils/i18n", () => ({ - getTranslatedLabel: (value: string) => value, -})); - -vi.mock("./WebRTCPlayer", () => ({ - default: ({ className }: { className?: string }) => ( - - ), -})); - -vi.mock("./MsePlayer", () => ({ - default: ({ className }: { className?: string }) => ( - - ), -})); - -vi.mock("./JSMpegPlayer", () => ({ - default: ({ className }: { className?: string }) => ( -
jsmpeg
- ), -})); - -vi.mock("../camera/AutoUpdatingCameraImage", () => ({ - default: () =>
still
, -})); - -vi.mock("../overlay/ImageShadowOverlay", () => ({ - ImageShadowOverlay: () =>
, -})); - -vi.mock("./PlayerStats", () => ({ - PlayerStats: () =>
, -})); - -const cameraConfig = { - name: "front_door", - detect: { width: 1920, height: 1080 }, -} as CameraConfig; - -describe("LivePlayer dashboard transform gating", () => { - it("does not apply rotate transform when applyDashboardTransforms is false", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).not.toContain("rotate(90deg)"); - }); - - it("applies rotate transform when dashboard transforms are enabled", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("rotate(90deg)"); - }); -}); diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 08b1ac2c9..fc2672b68 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -27,7 +27,6 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import { getTranslatedLabel } from "@/utils/i18n"; import { formatList } from "@/utils/stringUtil"; -import { useResizeObserver } from "@/hooks/resize-observer"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -48,9 +47,6 @@ type LivePlayerProps = { pip?: boolean; autoLive?: boolean; showStats?: boolean; - rotateClockwise?: boolean; - fillContainer?: boolean; - applyDashboardTransforms?: boolean; onClick?: () => void; setFullResolution?: React.Dispatch>; onError?: (error: LivePlayerError) => void; @@ -76,9 +72,6 @@ export default function LivePlayer({ pip, autoLive = true, showStats = false, - rotateClockwise = false, - fillContainer = false, - applyDashboardTransforms = false, onClick, setFullResolution, onError, @@ -90,31 +83,10 @@ export default function LivePlayer({ const cameraName = useCameraFriendlyName(cameraConfig); - const shouldFillContainer = applyDashboardTransforms && fillContainer; - const shouldRotateClockwise = applyDashboardTransforms && rotateClockwise; + // player is showing on a dashboard if containerRef is not provided - const mediaViewportRef = useRef(null); - const [{ width: viewportWidth, height: viewportHeight }] = - useResizeObserver(mediaViewportRef); + const inDashboard = containerRef?.current == null; - const mediaTransformStyle = useMemo(() => { - const transforms = ["translate(-50%, -50%)"]; - - if (shouldRotateClockwise) { - transforms.push("rotate(90deg)"); - } - - // For a 90° rotation, the media box must use swapped viewport dimensions - // before rotating, otherwise the rotated content can under-fill one axis. - const rotatedWidth = viewportHeight ? `${viewportHeight}px` : "100%"; - const rotatedHeight = viewportWidth ? `${viewportWidth}px` : "100%"; - - return { - transform: transforms.join(" "), - width: shouldRotateClockwise ? rotatedWidth : "100%", - height: shouldRotateClockwise ? rotatedHeight : "100%", - }; - }, [shouldRotateClockwise, viewportHeight, viewportWidth]); // stats const [stats, setStats] = useState({ @@ -307,11 +279,7 @@ export default function LivePlayer({ player = ( ); } else { @@ -412,17 +371,7 @@ export default function LivePlayer({ lowerClassName="md:rounded-2xl" /> )} -
-
- {player} -
-
+ {player} {cameraEnabled && !offline && (!showStillWithoutActivity || isReEnabling) && @@ -492,15 +441,8 @@ export default function LivePlayer({ )} >
- {offline && applyDashboardTransforms && ( + {offline && inDashboard && ( <>
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index e3f794602..dcf3c312f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -8,7 +8,6 @@ export interface UiConfig { time_style?: "full" | "long" | "medium" | "short"; dashboard: boolean; order: number; - rotate: boolean; unit_system?: "metric" | "imperial"; } diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index bfa4e67ac..91fba4483 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -27,6 +27,7 @@ import { StatsState, VolumeState, } from "@/types/live"; +import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { Skeleton } from "@/components/ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isEqual } from "lodash"; @@ -193,20 +194,38 @@ export default function DraggableGridLayout({ return; } - // Keep birdseye aspect-aware sizing, while camera tiles use a stable size. - const columnsPerPlayer = 4; - const col = index % 3; - let width = columnsPerPlayer; - let height = columnsPerPlayer; + let aspectRatio; + let col; + // Handle "birdseye" camera as a special case if (cameraName === "birdseye") { - const aspectRatio = + aspectRatio = (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); - if (aspectRatio < 1) { - height = 2 * columnsPerPlayer; - } else if (aspectRatio > 2) { - width = 2 * columnsPerPlayer; - } + col = 0; // Set birdseye camera in the first column + } else { + const camera = cameras.find((cam) => cam.name === cameraName); + aspectRatio = + (camera && camera?.detect.width / camera?.detect.height) || 16 / 9; + col = index % 3; // Regular cameras distributed across columns + } + + // Calculate layout options based on aspect ratio + const columnsPerPlayer = 4; + let height; + let width; + + if (aspectRatio < 1) { + // Portrait + height = 2 * columnsPerPlayer; + width = columnsPerPlayer; + } else if (aspectRatio > 2) { + // Wide + height = 1 * columnsPerPlayer; + width = 2 * columnsPerPlayer; + } else { + // Landscape + height = 1 * columnsPerPlayer; + width = columnsPerPlayer; } const options = { @@ -587,7 +606,15 @@ export default function DraggableGridLayout({ )} {cameras.map((camera) => { - const grow = "size-full"; + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > ASPECT_WIDE_LAYOUT) { + grow = `aspect-wide w-full`; + } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { + grow = `aspect-tall h-full`; + } else { + grow = "aspect-video"; + } const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; @@ -654,7 +681,8 @@ export default function DraggableGridLayout({ useWebGL={useWebGL} cameraRef={cameraRef} className={cn( - "size-full overflow-hidden rounded-lg bg-black md:rounded-2xl", + "rounded-lg bg-black md:rounded-2xl", + grow, isEditMode && showCircles && "outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", @@ -681,9 +709,6 @@ export default function DraggableGridLayout({ onResetLiveMode={() => resetPreferredLiveMode(camera.name)} playAudio={audioStates[camera.name]} volume={volumeStates[camera.name]} - rotateClockwise={camera.ui.rotate} - fillContainer - applyDashboardTransforms /> {isEditMode && showCircles && } diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index bec186868..7e808f33f 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -511,7 +511,16 @@ export default function LiveDashboardView({
)} {cameras.map((camera) => { - const grow = "aspect-video"; + let grow; + const aspectRatio = + camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`; + } else if (aspectRatio < 1) { + grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`; + } else { + grow = "aspect-video"; + } const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; @@ -575,7 +584,7 @@ export default function LiveDashboardView({ );