mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-21 23:58:22 +03:00
Merge pull request #14 from ibs0d/codex/add-camera-rotation-config-option
Add per-camera dashboard rotation and cover-fit support for live views
This commit is contained in:
commit
27245af7d3
@ -16,3 +16,8 @@ class CameraUiConfig(FrigateBaseModel):
|
|||||||
title="Show in UI",
|
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.",
|
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.",
|
||||||
|
)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ type AutoUpdatingCameraImageProps = {
|
|||||||
cameraClasses?: string;
|
cameraClasses?: string;
|
||||||
reloadInterval?: number;
|
reloadInterval?: number;
|
||||||
periodicCache?: boolean;
|
periodicCache?: boolean;
|
||||||
|
fit?: "contain" | "cover";
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
const MIN_LOAD_TIMEOUT_MS = 200;
|
||||||
@ -21,6 +22,7 @@ export default function AutoUpdatingCameraImage({
|
|||||||
cameraClasses,
|
cameraClasses,
|
||||||
reloadInterval = MIN_LOAD_TIMEOUT_MS,
|
reloadInterval = MIN_LOAD_TIMEOUT_MS,
|
||||||
periodicCache = false,
|
periodicCache = false,
|
||||||
|
fit = "contain",
|
||||||
}: AutoUpdatingCameraImageProps) {
|
}: AutoUpdatingCameraImageProps) {
|
||||||
const [key, setKey] = useState(Date.now());
|
const [key, setKey] = useState(Date.now());
|
||||||
const [fps, setFps] = useState<string>("0");
|
const [fps, setFps] = useState<string>("0");
|
||||||
@ -96,6 +98,7 @@ export default function AutoUpdatingCameraImage({
|
|||||||
onload={handleLoad}
|
onload={handleLoad}
|
||||||
searchParams={cacheKey}
|
searchParams={cacheKey}
|
||||||
className={cameraClasses}
|
className={cameraClasses}
|
||||||
|
fit={fit}
|
||||||
/>
|
/>
|
||||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type CameraImageProps = {
|
|||||||
camera: string;
|
camera: string;
|
||||||
onload?: () => void;
|
onload?: () => void;
|
||||||
searchParams?: string;
|
searchParams?: string;
|
||||||
|
fit?: "contain" | "cover";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CameraImage({
|
export default function CameraImage({
|
||||||
@ -19,6 +20,7 @@ export default function CameraImage({
|
|||||||
camera,
|
camera,
|
||||||
onload,
|
onload,
|
||||||
searchParams = "",
|
searchParams = "",
|
||||||
|
fit = "contain",
|
||||||
}: CameraImageProps) {
|
}: CameraImageProps) {
|
||||||
const { data: config } = useSWR("config");
|
const { data: config } = useSWR("config");
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
@ -87,12 +89,16 @@ export default function CameraImage({
|
|||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
fit == "cover" ? "size-full object-cover" : "object-contain",
|
||||||
imageLoaded
|
fit == "cover"
|
||||||
? isPortraitImage
|
? imageLoaded
|
||||||
? "h-full w-auto"
|
? "visible"
|
||||||
: "h-auto w-full"
|
: "invisible"
|
||||||
: "invisible",
|
: imageLoaded
|
||||||
|
? isPortraitImage
|
||||||
|
? "h-full w-auto"
|
||||||
|
: "h-auto w-full"
|
||||||
|
: "invisible",
|
||||||
"rounded-lg md:rounded-2xl",
|
"rounded-lg md:rounded-2xl",
|
||||||
)}
|
)}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type JSMpegPlayerProps = {
|
|||||||
useWebGL: boolean;
|
useWebGL: boolean;
|
||||||
setStats?: (stats: PlayerStatsType) => void;
|
setStats?: (stats: PlayerStatsType) => void;
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
|
fit?: "contain" | "cover";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function JSMpegPlayer({
|
export default function JSMpegPlayer({
|
||||||
@ -28,6 +29,7 @@ export default function JSMpegPlayer({
|
|||||||
useWebGL = false,
|
useWebGL = false,
|
||||||
setStats,
|
setStats,
|
||||||
onPlaying,
|
onPlaying,
|
||||||
|
fit = "contain",
|
||||||
}: JSMpegPlayerProps) {
|
}: JSMpegPlayerProps) {
|
||||||
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
||||||
const videoRef = useRef<HTMLDivElement>(null);
|
const videoRef = useRef<HTMLDivElement>(null);
|
||||||
@ -60,8 +62,28 @@ export default function JSMpegPlayer({
|
|||||||
[containerWidth, containerHeight],
|
[containerWidth, containerHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scaledHeight = useMemo(() => {
|
const scaledDimensions = useMemo(() => {
|
||||||
if (selectedContainerRef?.current && width && height) {
|
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 =
|
const scaledHeight =
|
||||||
aspectRatio < (fitAspect ?? 0)
|
aspectRatio < (fitAspect ?? 0)
|
||||||
? Math.floor(
|
? Math.floor(
|
||||||
@ -78,33 +100,31 @@ export default function JSMpegPlayer({
|
|||||||
: Math.min(scaledHeight, height);
|
: Math.min(scaledHeight, height);
|
||||||
|
|
||||||
if (finalHeight > 0) {
|
if (finalHeight > 0) {
|
||||||
return finalHeight;
|
return {
|
||||||
|
width: Math.ceil(finalHeight * aspectRatio),
|
||||||
|
height: finalHeight,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
|
return { width: undefined, height: undefined };
|
||||||
}, [
|
}, [
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
containerHeight,
|
containerHeight,
|
||||||
fitAspect,
|
fitAspect,
|
||||||
|
fit,
|
||||||
height,
|
height,
|
||||||
width,
|
width,
|
||||||
stretch,
|
stretch,
|
||||||
selectedContainerRef,
|
selectedContainerRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const scaledWidth = useMemo(() => {
|
|
||||||
if (aspectRatio && scaledHeight) {
|
|
||||||
return Math.ceil(scaledHeight * aspectRatio);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [scaledHeight, aspectRatio]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scaledWidth && scaledHeight) {
|
if (scaledDimensions.width && scaledDimensions.height) {
|
||||||
setDimensionsReady(true);
|
setDimensionsReady(true);
|
||||||
}
|
}
|
||||||
}, [scaledWidth, scaledHeight]);
|
}, [scaledDimensions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPlayingRef.current = onPlaying;
|
onPlayingRef.current = onPlaying;
|
||||||
@ -242,7 +262,7 @@ export default function JSMpegPlayer({
|
|||||||
<div
|
<div
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"jsmpeg flex h-full w-auto items-center justify-center",
|
"jsmpeg flex size-full items-center justify-center overflow-hidden",
|
||||||
!showCanvas && "hidden",
|
!showCanvas && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -250,8 +270,8 @@ export default function JSMpegPlayer({
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="rounded-lg md:rounded-2xl"
|
className="rounded-lg md:rounded-2xl"
|
||||||
style={{
|
style={{
|
||||||
width: scaledWidth,
|
width: scaledDimensions.width,
|
||||||
height: scaledHeight,
|
height: scaledDimensions.height,
|
||||||
}}
|
}}
|
||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
104
web/src/components/player/LivePlayer.test.tsx
Normal file
104
web/src/components/player/LivePlayer.test.tsx
Normal file
@ -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 }) => (
|
||||||
|
<video className={className}>webrtc</video>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./MsePlayer", () => ({
|
||||||
|
default: ({ className }: { className?: string }) => (
|
||||||
|
<video className={className}>mse</video>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./JSMpegPlayer", () => ({
|
||||||
|
default: ({ className }: { className?: string }) => (
|
||||||
|
<div className={className}>jsmpeg</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../camera/AutoUpdatingCameraImage", () => ({
|
||||||
|
default: () => <div>still</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../overlay/ImageShadowOverlay", () => ({
|
||||||
|
ImageShadowOverlay: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./PlayerStats", () => ({
|
||||||
|
PlayerStats: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<LivePlayer
|
||||||
|
cameraConfig={cameraConfig}
|
||||||
|
streamName="front_door"
|
||||||
|
preferredLiveMode="webrtc"
|
||||||
|
useWebGL={false}
|
||||||
|
playInBackground={false}
|
||||||
|
rotateClockwise
|
||||||
|
fillContainer
|
||||||
|
applyDashboardTransforms={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).not.toContain("rotate(90deg)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies rotate transform when dashboard transforms are enabled", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<LivePlayer
|
||||||
|
cameraConfig={cameraConfig}
|
||||||
|
streamName="front_door"
|
||||||
|
preferredLiveMode="webrtc"
|
||||||
|
useWebGL={false}
|
||||||
|
playInBackground={false}
|
||||||
|
rotateClockwise
|
||||||
|
fillContainer
|
||||||
|
applyDashboardTransforms
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("rotate(90deg)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -27,6 +27,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|||||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { formatList } from "@/utils/stringUtil";
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
|
|
||||||
type LivePlayerProps = {
|
type LivePlayerProps = {
|
||||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||||
@ -47,6 +48,9 @@ type LivePlayerProps = {
|
|||||||
pip?: boolean;
|
pip?: boolean;
|
||||||
autoLive?: boolean;
|
autoLive?: boolean;
|
||||||
showStats?: boolean;
|
showStats?: boolean;
|
||||||
|
rotateClockwise?: boolean;
|
||||||
|
fillContainer?: boolean;
|
||||||
|
applyDashboardTransforms?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||||
onError?: (error: LivePlayerError) => void;
|
onError?: (error: LivePlayerError) => void;
|
||||||
@ -72,6 +76,9 @@ export default function LivePlayer({
|
|||||||
pip,
|
pip,
|
||||||
autoLive = true,
|
autoLive = true,
|
||||||
showStats = false,
|
showStats = false,
|
||||||
|
rotateClockwise = false,
|
||||||
|
fillContainer = false,
|
||||||
|
applyDashboardTransforms = false,
|
||||||
onClick,
|
onClick,
|
||||||
setFullResolution,
|
setFullResolution,
|
||||||
onError,
|
onError,
|
||||||
@ -83,10 +90,31 @@ export default function LivePlayer({
|
|||||||
|
|
||||||
const cameraName = useCameraFriendlyName(cameraConfig);
|
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<HTMLDivElement | null>(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
|
// stats
|
||||||
|
|
||||||
const [stats, setStats] = useState<PlayerStatsType>({
|
const [stats, setStats] = useState<PlayerStatsType>({
|
||||||
@ -279,7 +307,11 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<WebRtcPlayer
|
<WebRtcPlayer
|
||||||
key={"webrtc_" + key}
|
key={"webrtc_" + key}
|
||||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
className={cn(
|
||||||
|
"size-full rounded-lg md:rounded-2xl",
|
||||||
|
shouldFillContainer && "object-cover",
|
||||||
|
liveReady ? "" : "hidden",
|
||||||
|
)}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
getStats={showStats}
|
getStats={showStats}
|
||||||
@ -298,7 +330,11 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<MSEPlayer
|
<MSEPlayer
|
||||||
key={"mse_" + key}
|
key={"mse_" + key}
|
||||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
className={cn(
|
||||||
|
"size-full rounded-lg md:rounded-2xl",
|
||||||
|
shouldFillContainer && "object-cover",
|
||||||
|
liveReady ? "" : "hidden",
|
||||||
|
)}
|
||||||
camera={streamName}
|
camera={streamName}
|
||||||
playbackEnabled={cameraActive || liveReady}
|
playbackEnabled={cameraActive || liveReady}
|
||||||
audioEnabled={playAudio}
|
audioEnabled={playAudio}
|
||||||
@ -324,7 +360,11 @@ export default function LivePlayer({
|
|||||||
player = (
|
player = (
|
||||||
<JSMpegPlayer
|
<JSMpegPlayer
|
||||||
key={"jsmpeg_" + key}
|
key={"jsmpeg_" + key}
|
||||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
className={cn(
|
||||||
|
"flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
||||||
|
shouldFillContainer &&
|
||||||
|
"[&_.internal-jsmpeg-container]:size-full [&_.jsmpeg]:size-full",
|
||||||
|
)}
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
width={cameraConfig.detect.width}
|
width={cameraConfig.detect.width}
|
||||||
height={cameraConfig.detect.height}
|
height={cameraConfig.detect.height}
|
||||||
@ -335,6 +375,7 @@ export default function LivePlayer({
|
|||||||
setStats={setStats}
|
setStats={setStats}
|
||||||
containerRef={containerRef ?? internalContainerRef}
|
containerRef={containerRef ?? internalContainerRef}
|
||||||
onPlaying={playerIsPlaying}
|
onPlaying={playerIsPlaying}
|
||||||
|
fit={shouldFillContainer ? "cover" : "contain"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -371,7 +412,17 @@ export default function LivePlayer({
|
|||||||
lowerClassName="md:rounded-2xl"
|
lowerClassName="md:rounded-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{player}
|
<div
|
||||||
|
ref={mediaViewportRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0",
|
||||||
|
shouldFillContainer && "overflow-hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute left-1/2 top-1/2" style={mediaTransformStyle}>
|
||||||
|
{player}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{cameraEnabled &&
|
{cameraEnabled &&
|
||||||
!offline &&
|
!offline &&
|
||||||
(!showStillWithoutActivity || isReEnabling) &&
|
(!showStillWithoutActivity || isReEnabling) &&
|
||||||
@ -441,8 +492,15 @@ export default function LivePlayer({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AutoUpdatingCameraImage
|
<AutoUpdatingCameraImage
|
||||||
className="pointer-events-none size-full"
|
className={cn(
|
||||||
cameraClasses="relative size-full flex justify-center"
|
"pointer-events-none size-full",
|
||||||
|
shouldFillContainer && "overflow-hidden",
|
||||||
|
)}
|
||||||
|
cameraClasses={cn(
|
||||||
|
"relative size-full",
|
||||||
|
shouldFillContainer && "overflow-hidden",
|
||||||
|
)}
|
||||||
|
fit={shouldFillContainer ? "cover" : "contain"}
|
||||||
camera={cameraConfig.name}
|
camera={cameraConfig.name}
|
||||||
showFps={false}
|
showFps={false}
|
||||||
reloadInterval={stillReloadInterval}
|
reloadInterval={stillReloadInterval}
|
||||||
@ -450,7 +508,7 @@ export default function LivePlayer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{offline && inDashboard && (
|
{offline && applyDashboardTransforms && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||||
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface UiConfig {
|
|||||||
time_style?: "full" | "long" | "medium" | "short";
|
time_style?: "full" | "long" | "medium" | "short";
|
||||||
dashboard: boolean;
|
dashboard: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
|
rotate: boolean;
|
||||||
unit_system?: "metric" | "imperial";
|
unit_system?: "metric" | "imperial";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import {
|
|||||||
StatsState,
|
StatsState,
|
||||||
VolumeState,
|
VolumeState,
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
@ -194,38 +193,20 @@ export default function DraggableGridLayout({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let aspectRatio;
|
// Keep birdseye aspect-aware sizing, while camera tiles use a stable size.
|
||||||
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
|
|
||||||
const columnsPerPlayer = 4;
|
const columnsPerPlayer = 4;
|
||||||
let height;
|
const col = index % 3;
|
||||||
let width;
|
let width = columnsPerPlayer;
|
||||||
|
let height = columnsPerPlayer;
|
||||||
|
|
||||||
if (aspectRatio < 1) {
|
if (cameraName === "birdseye") {
|
||||||
// Portrait
|
const aspectRatio =
|
||||||
height = 2 * columnsPerPlayer;
|
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||||
width = columnsPerPlayer;
|
if (aspectRatio < 1) {
|
||||||
} else if (aspectRatio > 2) {
|
height = 2 * columnsPerPlayer;
|
||||||
// Wide
|
} else if (aspectRatio > 2) {
|
||||||
height = 1 * columnsPerPlayer;
|
width = 2 * columnsPerPlayer;
|
||||||
width = 2 * columnsPerPlayer;
|
}
|
||||||
} else {
|
|
||||||
// Landscape
|
|
||||||
height = 1 * columnsPerPlayer;
|
|
||||||
width = columnsPerPlayer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
@ -606,15 +587,7 @@ export default function DraggableGridLayout({
|
|||||||
</BirdseyeLivePlayerGridItem>
|
</BirdseyeLivePlayerGridItem>
|
||||||
)}
|
)}
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
let grow;
|
const grow = "size-full";
|
||||||
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 availableStreams = camera.live.streams || {};
|
||||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||||
|
|
||||||
@ -681,8 +654,7 @@ export default function DraggableGridLayout({
|
|||||||
useWebGL={useWebGL}
|
useWebGL={useWebGL}
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg bg-black md:rounded-2xl",
|
"size-full overflow-hidden rounded-lg bg-black md:rounded-2xl",
|
||||||
grow,
|
|
||||||
isEditMode &&
|
isEditMode &&
|
||||||
showCircles &&
|
showCircles &&
|
||||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
"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)}
|
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||||
playAudio={audioStates[camera.name]}
|
playAudio={audioStates[camera.name]}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name]}
|
||||||
|
rotateClockwise={camera.ui.rotate}
|
||||||
|
fillContainer
|
||||||
|
applyDashboardTransforms
|
||||||
/>
|
/>
|
||||||
{isEditMode && showCircles && <CornerCircles />}
|
{isEditMode && showCircles && <CornerCircles />}
|
||||||
</GridLiveContextMenu>
|
</GridLiveContextMenu>
|
||||||
|
|||||||
@ -511,16 +511,7 @@ export default function LiveDashboardView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cameras.map((camera) => {
|
{cameras.map((camera) => {
|
||||||
let grow;
|
const grow = "aspect-video";
|
||||||
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 availableStreams = camera.live.streams || {};
|
||||||
const firstStreamEntry =
|
const firstStreamEntry =
|
||||||
Object.values(availableStreams)[0] || "";
|
Object.values(availableStreams)[0] || "";
|
||||||
@ -584,7 +575,7 @@ export default function LiveDashboardView({
|
|||||||
<LivePlayer
|
<LivePlayer
|
||||||
cameraRef={cameraRef}
|
cameraRef={cameraRef}
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
className={`${grow} size-full overflow-hidden rounded-lg bg-black md:rounded-2xl`}
|
||||||
windowVisible={
|
windowVisible={
|
||||||
windowVisible && visibleCameras.includes(camera.name)
|
windowVisible && visibleCameras.includes(camera.name)
|
||||||
}
|
}
|
||||||
@ -608,6 +599,9 @@ export default function LiveDashboardView({
|
|||||||
}
|
}
|
||||||
playAudio={audioStates[camera.name] ?? false}
|
playAudio={audioStates[camera.name] ?? false}
|
||||||
volume={volumeStates[camera.name]}
|
volume={volumeStates[camera.name]}
|
||||||
|
rotateClockwise={camera.ui.rotate}
|
||||||
|
fillContainer
|
||||||
|
applyDashboardTransforms
|
||||||
/>
|
/>
|
||||||
</LiveContextMenu>
|
</LiveContextMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user