From e774760714c9e75817caa79241166fd30cb18eb6 Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:48:06 +1100 Subject: [PATCH] Fix LivePlayer regression by gating dashboard transform path --- 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 | 149 ++++++++++++++---- web/src/types/frigateConfig.ts | 1 + web/src/views/live/DraggableGridLayout.tsx | 59 ++----- web/src/views/live/LiveDashboardView.tsx | 16 +- 9 files changed, 305 insertions(+), 102 deletions(-) create mode 100644 web/src/components/player/LivePlayer.test.tsx diff --git a/frigate/config/camera/ui.py b/frigate/config/camera/ui.py index 5e903b254..01f715f58 100644 --- a/frigate/config/camera/ui.py +++ b/frigate/config/camera/ui.py @@ -16,3 +16,8 @@ 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 95d90d9bd..1a5b9b033 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -9,6 +9,7 @@ type AutoUpdatingCameraImageProps = { cameraClasses?: string; reloadInterval?: number; periodicCache?: boolean; + fit?: "contain" | "cover"; }; const MIN_LOAD_TIMEOUT_MS = 200; @@ -21,6 +22,7 @@ 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"); @@ -96,6 +98,7 @@ 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 f0c05995e..b3de66d64 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -12,6 +12,7 @@ type CameraImageProps = { camera: string; onload?: () => void; searchParams?: string; + fit?: "contain" | "cover"; }; export default function CameraImage({ @@ -19,6 +20,7 @@ export default function CameraImage({ camera, onload, searchParams = "", + fit = "contain", }: CameraImageProps) { const { data: config } = useSWR("config"); const apiHost = useApiHost(); @@ -87,12 +89,16 @@ export default function CameraImage({ void; onPlaying?: () => void; + fit?: "contain" | "cover"; }; export default function JSMpegPlayer({ @@ -28,6 +29,7 @@ export default function JSMpegPlayer({ useWebGL = false, setStats, onPlaying, + fit = "contain", }: JSMpegPlayerProps) { const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const videoRef = useRef(null); @@ -60,8 +62,28 @@ export default function JSMpegPlayer({ [containerWidth, containerHeight], ); - const scaledHeight = useMemo(() => { - if (selectedContainerRef?.current && width && height) { + 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 = aspectRatio < (fitAspect ?? 0) ? Math.floor( @@ -78,33 +100,31 @@ export default function JSMpegPlayer({ : Math.min(scaledHeight, height); if (finalHeight > 0) { - return finalHeight; + return { + width: Math.ceil(finalHeight * aspectRatio), + height: finalHeight, + }; } } - return undefined; + + return { width: undefined, height: 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 (scaledWidth && scaledHeight) { + if (scaledDimensions.width && scaledDimensions.height) { setDimensionsReady(true); } - }, [scaledWidth, scaledHeight]); + }, [scaledDimensions]); useEffect(() => { onPlayingRef.current = onPlaying; @@ -242,7 +262,7 @@ export default function JSMpegPlayer({
@@ -250,8 +270,8 @@ export default function JSMpegPlayer({ ref={canvasRef} className="rounded-lg md:rounded-2xl" style={{ - width: scaledWidth, - height: scaledHeight, + width: scaledDimensions.width, + height: scaledDimensions.height, }} >
diff --git a/web/src/components/player/LivePlayer.test.tsx b/web/src/components/player/LivePlayer.test.tsx new file mode 100644 index 000000000..60dafa210 --- /dev/null +++ b/web/src/components/player/LivePlayer.test.tsx @@ -0,0 +1,104 @@ +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 fc2672b68..8fba27bda 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -27,6 +27,7 @@ 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; @@ -47,6 +48,9 @@ type LivePlayerProps = { pip?: boolean; autoLive?: boolean; showStats?: boolean; + rotateClockwise?: boolean; + fillContainer?: boolean; + applyDashboardTransforms?: boolean; onClick?: () => void; setFullResolution?: React.Dispatch>; onError?: (error: LivePlayerError) => void; @@ -72,6 +76,9 @@ export default function LivePlayer({ pip, autoLive = true, showStats = false, + rotateClockwise = false, + fillContainer = false, + applyDashboardTransforms = false, onClick, setFullResolution, onError, @@ -83,10 +90,31 @@ export default function LivePlayer({ const cameraName = useCameraFriendlyName(cameraConfig); - // player is showing on a dashboard if containerRef is not provided + const shouldFillContainer = applyDashboardTransforms && fillContainer; + const shouldRotateClockwise = applyDashboardTransforms && rotateClockwise; - const inDashboard = containerRef?.current == null; + const mediaViewportRef = useRef(null); + const [{ width: viewportWidth, height: viewportHeight }] = + useResizeObserver(mediaViewportRef); + 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({ @@ -279,7 +307,11 @@ export default function LivePlayer({ player = ( ); } else { @@ -371,7 +412,24 @@ export default function LivePlayer({ lowerClassName="md:rounded-2xl" /> )} - {player} + {applyDashboardTransforms ? ( +
+
+ {player} +
+
+ ) : ( + player + )} {cameraEnabled && !offline && (!showStillWithoutActivity || isReEnabling) && @@ -429,28 +487,65 @@ export default function LivePlayer({
)} -
- -
+ {applyDashboardTransforms ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} - {offline && inDashboard && ( + {offline && applyDashboardTransforms && ( <>
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index dcf3c312f..e3f794602 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -8,6 +8,7 @@ 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 91fba4483..bfa4e67ac 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -27,7 +27,6 @@ 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"; @@ -194,38 +193,20 @@ export default function DraggableGridLayout({ return; } - let aspectRatio; - let col; - - // Handle "birdseye" camera as a special case - if (cameraName === "birdseye") { - aspectRatio = - (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); - 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 + // Keep birdseye aspect-aware sizing, while camera tiles use a stable size. const columnsPerPlayer = 4; - let height; - let width; + const col = index % 3; + let width = columnsPerPlayer; + let height = columnsPerPlayer; - 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; + if (cameraName === "birdseye") { + const aspectRatio = + (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); + if (aspectRatio < 1) { + height = 2 * columnsPerPlayer; + } else if (aspectRatio > 2) { + width = 2 * columnsPerPlayer; + } } const options = { @@ -606,15 +587,7 @@ export default function DraggableGridLayout({ )} {cameras.map((camera) => { - 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 grow = "size-full"; const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; @@ -681,8 +654,7 @@ export default function DraggableGridLayout({ useWebGL={useWebGL} cameraRef={cameraRef} className={cn( - "rounded-lg bg-black md:rounded-2xl", - grow, + "size-full overflow-hidden rounded-lg bg-black md:rounded-2xl", isEditMode && showCircles && "outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", @@ -709,6 +681,9 @@ 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 7e808f33f..bec186868 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -511,16 +511,7 @@ export default function LiveDashboardView({
)} {cameras.map((camera) => { - 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 grow = "aspect-video"; const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; @@ -584,7 +575,7 @@ export default function LiveDashboardView({ );