mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 00:58:58 +03:00
rework live dashboard grid layout and add natural mode
This commit is contained in:
@@ -103,7 +103,7 @@ export default function CameraImage({
|
||||
)}
|
||||
{!imageLoaded && enabled ? (
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
|
||||
upperClassName,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
|
||||
lowerClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { LuInfo, LuTriangleAlert } from "react-icons/lu";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
importedLayoutsNaturalAspect,
|
||||
ImportSummary,
|
||||
TransferSection,
|
||||
UiSettingsFile,
|
||||
@@ -25,6 +26,7 @@ type ImportUiSettingsDialogProps = {
|
||||
fileName: string;
|
||||
file: UiSettingsFile;
|
||||
summary: ImportSummary;
|
||||
currentNaturalAspect: boolean;
|
||||
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -34,6 +36,7 @@ export default function ImportUiSettingsDialog({
|
||||
fileName,
|
||||
file,
|
||||
summary,
|
||||
currentNaturalAspect,
|
||||
onConfirm,
|
||||
}: ImportUiSettingsDialogProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
@@ -90,6 +93,16 @@ export default function ImportUiSettingsDialog({
|
||||
[sections.streaming, summary.unknownCameras],
|
||||
);
|
||||
|
||||
// importing layouts also applies the tile-sizing mode they were built for
|
||||
const layoutsModeChange = useMemo(() => {
|
||||
if (!sections.layouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = importedLayoutsNaturalAspect(file);
|
||||
return mode === null || mode === currentNaturalAspect ? null : mode;
|
||||
}, [sections.layouts, file, currentNaturalAspect]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsImporting(true);
|
||||
await onConfirm(sections);
|
||||
@@ -156,6 +169,19 @@ export default function ImportUiSettingsDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layoutsModeChange !== null && (
|
||||
<Alert variant="info">
|
||||
<LuInfo className="size-5" />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
layoutsModeChange
|
||||
? "general.backupRestore.importDialog.layoutsModeOn"
|
||||
: "general.backupRestore.importDialog.layoutsModeOff",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(visibleUnknownGroups.length > 0 ||
|
||||
visibleUnknownCameras.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
|
||||
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
let player;
|
||||
if (liveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else if (liveMode == "mse") {
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl`}
|
||||
camera="birdseye"
|
||||
pip={pip}
|
||||
/>
|
||||
);
|
||||
player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
|
||||
} else {
|
||||
player = (
|
||||
<div className="w-5xl text-center text-sm">
|
||||
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex size-full justify-center overflow-hidden"
|
||||
camera="birdseye"
|
||||
width={birdseyeConfig.width}
|
||||
height={birdseyeConfig.height}
|
||||
@@ -65,22 +53,33 @@ export default function BirdseyeLivePlayer({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center",
|
||||
// matches LivePlayer: the card owns the corner and clips everything
|
||||
// inside it, so the stream and the overlays stay concentric with it
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={
|
||||
{
|
||||
"--pic-ar":
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-full" ref={playerRef}>
|
||||
{player}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,10 @@ type LivePlayerProps = {
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
// Aspect of the source currently on screen: the live stream's while it is
|
||||
// playing, undefined while the still image is showing. The still comes from
|
||||
// the detect stream, which can be shaped differently than the live one.
|
||||
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
@@ -80,6 +84,7 @@ export default function LivePlayer({
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
onResetLiveMode,
|
||||
onLiveAspectChange,
|
||||
}: LivePlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
|
||||
@@ -127,6 +132,37 @@ export default function LivePlayer({
|
||||
// camera live state
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
const [liveAspect, setLiveAspect] = useState<number | undefined>();
|
||||
|
||||
const handleFullResolution = useCallback(
|
||||
(value: React.SetStateAction<VideoResolutionType>) => {
|
||||
setFullResolution?.(value);
|
||||
|
||||
if (typeof value === "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveAspect(
|
||||
value.width && value.height ? value.width / value.height : undefined,
|
||||
);
|
||||
},
|
||||
[setFullResolution],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLiveAspectChange?.(liveReady ? liveAspect : undefined);
|
||||
}, [liveReady, liveAspect, onLiveAspectChange]);
|
||||
|
||||
// The card can be a different shape than the picture (a bucketed tile, or a
|
||||
// still whose detect aspect differs from the stream), so overlays that are
|
||||
// meant to sit on the image have to be fitted to it rather than to the card.
|
||||
const pictureAspect = useMemo(() => {
|
||||
if (liveReady && liveAspect) {
|
||||
return liveAspect;
|
||||
}
|
||||
const { width, height } = cameraConfig.detect;
|
||||
return width && height ? width / height : 16 / 9;
|
||||
}, [liveReady, liveAspect, cameraConfig.detect]);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
const cameraActiveRef = useRef(cameraActive);
|
||||
@@ -262,11 +298,12 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
key={"webrtc_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
setFullResolution={handleFullResolution}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
@@ -282,7 +319,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<MSEPlayer
|
||||
key={"mse_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
className={`size-full ${liveReady ? "" : "hidden"}`}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
@@ -292,7 +329,7 @@ export default function LivePlayer({
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
setFullResolution={handleFullResolution}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
@@ -308,7 +345,7 @@ export default function LivePlayer({
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
key={"jsmpeg_" + key}
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
className="flex justify-center overflow-hidden"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
height={cameraConfig.detect.height}
|
||||
@@ -325,7 +362,9 @@ export default function LivePlayer({
|
||||
player = null;
|
||||
}
|
||||
} else {
|
||||
player = <ActivityIndicator />;
|
||||
player = (
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -340,10 +379,12 @@ export default function LivePlayer({
|
||||
}}
|
||||
data-camera={cameraConfig.name}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer justify-center outline",
|
||||
// the card owns the corner: overflow-hidden clips the stream, the still
|
||||
// image, and every overlay to this one radius so they stay concentric
|
||||
"relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl",
|
||||
activeTracking &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady)
|
||||
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
|
||||
? "shadow-severity_alert outline-[3px] outline-severity_alert"
|
||||
: "outline-0 outline-background",
|
||||
"transition-all duration-500",
|
||||
className,
|
||||
@@ -357,18 +398,24 @@ export default function LivePlayer({
|
||||
>
|
||||
{cameraEnabled &&
|
||||
((showStillWithoutActivity && !liveReady) || liveReady) && (
|
||||
<ImageShadowOverlay
|
||||
upperClassName="md:rounded-2xl"
|
||||
lowerClassName="md:rounded-2xl"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
|
||||
style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
|
||||
>
|
||||
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
|
||||
<ImageShadowOverlay />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{player}
|
||||
{cameraEnabled &&
|
||||
!offline &&
|
||||
(!showStillWithoutActivity || isReEnabling) &&
|
||||
!liveReady && (
|
||||
(
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -447,7 +494,7 @@ export default function LivePlayer({
|
||||
|
||||
{offline && inDashboard && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" />
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<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="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
|
||||
<div>{t("streamOffline.title")}</div>
|
||||
@@ -491,7 +538,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
|
||||
{!cameraEnabled && (
|
||||
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt">
|
||||
<div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
|
||||
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
|
||||
<LuVideoOff className="mb-2 size-8 md:size-10" />
|
||||
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
TwoWayTalkError,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { webRTCIceServers } from "@/utils/webrtcUtil";
|
||||
@@ -20,6 +21,7 @@ type WebRtcPlayerProps = {
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onMicrophoneError?: (error: TwoWayTalkError) => void;
|
||||
@@ -36,6 +38,7 @@ export default function WebRtcPlayer({
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
setFullResolution,
|
||||
onPlaying,
|
||||
onError,
|
||||
onMicrophoneError,
|
||||
@@ -342,6 +345,12 @@ export default function WebRtcPlayer({
|
||||
if (videoLoadTimeoutRef.current) {
|
||||
clearTimeout(videoLoadTimeoutRef.current);
|
||||
}
|
||||
if (videoRef.current) {
|
||||
setFullResolution?.({
|
||||
width: videoRef.current.videoWidth,
|
||||
height: videoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
onPlaying?.();
|
||||
};
|
||||
|
||||
|
||||
@@ -175,6 +175,21 @@ html {
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner
|
||||
bracket on the real se handle (drop-shadow keeps it legible over footage). */
|
||||
.grid-layout .react-resizable-handle-se::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom: 2.5px solid rgba(233, 238, 246, 0.92);
|
||||
border-bottom-right-radius: 3px;
|
||||
filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
border: 3px solid #a00000 !important;
|
||||
opacity: 0.5 !important;
|
||||
|
||||
@@ -54,6 +54,12 @@ export const TRANSFER_KEYS: TransferKey[] = [
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "naturalAspectLayout",
|
||||
section: "preferences",
|
||||
namespaced: true,
|
||||
schema: z.boolean(),
|
||||
},
|
||||
{
|
||||
key: "alertVideos",
|
||||
section: "preferences",
|
||||
@@ -182,13 +188,22 @@ const layoutItemSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// a group whose dashboard has not been opened since upgrading still holds
|
||||
// the pre-0.19 bare array, so both shapes reach the file
|
||||
const storedLayoutSchema = z.union([
|
||||
z.array(layoutItemSchema),
|
||||
z
|
||||
.object({ version: z.number(), layout: z.array(layoutItemSchema) })
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
export const uiSettingsFileSchema = z.object({
|
||||
type: z.literal(UI_SETTINGS_FILE_TYPE),
|
||||
version: z.number().int().positive(),
|
||||
exported_at: z.string(),
|
||||
frigate_version: z.string(),
|
||||
sections: z.object({
|
||||
layouts: z.record(z.string(), z.array(layoutItemSchema)),
|
||||
layouts: z.record(z.string(), storedLayoutSchema),
|
||||
streaming: allGroupsStreamingSettingsSchema,
|
||||
preferences: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
@@ -384,6 +399,22 @@ export function summarizeImport(
|
||||
};
|
||||
}
|
||||
|
||||
// Bare arrays are pre-masonry bucketed layouts. A layout only renders under
|
||||
// the mode that built it, so importing layouts applies this mode too.
|
||||
export function importedLayoutsNaturalAspect(
|
||||
file: UiSettingsFile,
|
||||
): boolean | null {
|
||||
const layouts = Object.values(file.sections.layouts);
|
||||
|
||||
if (!layouts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return layouts.some(
|
||||
(layout) => !Array.isArray(layout) && layout.naturalAspect === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasImportableContent(summary: ImportSummary): boolean {
|
||||
return (
|
||||
summary.layoutGroupCount > 0 ||
|
||||
@@ -398,6 +429,9 @@ export async function applyImportPayload(
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const writes: Promise<void>[] = [];
|
||||
const layoutsMode = sections.layouts
|
||||
? importedLayoutsNaturalAspect(file)
|
||||
: null;
|
||||
|
||||
if (sections.layouts) {
|
||||
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
|
||||
@@ -408,6 +442,15 @@ export async function applyImportPayload(
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (layoutsMode !== null) {
|
||||
writes.push(
|
||||
setData(
|
||||
getUserNamespacedKey("naturalAspectLayout", username),
|
||||
layoutsMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const streamingEntry = TRANSFER_KEYS.find(
|
||||
@@ -447,6 +490,12 @@ export async function applyImportPayload(
|
||||
if (sections.preferences) {
|
||||
validPreferenceEntries(file.sections.preferences).forEach(
|
||||
({ entry, value }) => {
|
||||
// the layouts must win this key or they import into a mode that
|
||||
// cannot display them
|
||||
if (entry.key === "naturalAspectLayout" && layoutsMode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
writes.push(setData(storageKey(entry, username), value));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import {
|
||||
Layout,
|
||||
LayoutItem,
|
||||
ResponsiveGridLayout as Responsive,
|
||||
} from "react-grid-layout";
|
||||
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
import {
|
||||
@@ -28,9 +29,8 @@ import {
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { isEqual } from "lodash";
|
||||
import useSWR from "swr";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
@@ -52,6 +52,43 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w *
|
||||
// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS
|
||||
// also sets resize granularity: the aspect constraint derives height from
|
||||
// width, making one column the smallest step in both axes.
|
||||
const GRID_COLS = 96;
|
||||
const TILE_BASE_W = 32;
|
||||
const TILE_WIDE_W = 64;
|
||||
const VERTICAL_RESOLUTION = 4;
|
||||
const DEFAULT_ASPECT = 16 / 9;
|
||||
// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities.
|
||||
const TILE_ASPECT_WIDE = 32 / 9;
|
||||
const TILE_ASPECT_TALL = 8 / 9;
|
||||
|
||||
// Cells quantize to whole rows/columns, so the card takes the camera's exact
|
||||
// ratio and fits itself inside its cell. --ar and container-type live on the
|
||||
// cell; min() picks whichever axis binds first.
|
||||
const CARD_FIT =
|
||||
"h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]";
|
||||
|
||||
// Stored coordinates are grid units, so bump this whenever GRID_COLS or
|
||||
// VERTICAL_RESOLUTION changes in a released version.
|
||||
const LAYOUT_VERSION = 2;
|
||||
type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: Layout;
|
||||
};
|
||||
|
||||
// Without preventCollision, RGL shoves collided tiles down the page and never
|
||||
// compacts them back.
|
||||
const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true);
|
||||
|
||||
// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was
|
||||
// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly.
|
||||
const LEGACY_GRID_COLS = 12;
|
||||
const LEGACY_TILE_ROWS = 4;
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
cameraGroup: string;
|
||||
@@ -98,6 +135,64 @@ export default function DraggableGridLayout({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const aspectRatios = useMemo(() => {
|
||||
const map: { [key: string]: number } = {};
|
||||
if (birdseyeConfig) {
|
||||
map["birdseye"] =
|
||||
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1);
|
||||
}
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] =
|
||||
camera.detect.width / camera.detect.height || DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [cameras, birdseyeConfig]);
|
||||
|
||||
const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
const naturalAspectLayout = naturalAspectSetting ?? false;
|
||||
|
||||
// Bucketed mode snaps every camera to one of three tile shapes, matching the
|
||||
// pre-masonry layout; the picture letterboxes inside its bucket.
|
||||
const layoutAspects = useMemo(() => {
|
||||
if (naturalAspectLayout) {
|
||||
return aspectRatios;
|
||||
}
|
||||
const map: { [key: string]: number } = {};
|
||||
Object.entries(aspectRatios).forEach(([name, ratio]) => {
|
||||
map[name] =
|
||||
ratio > ASPECT_WIDE_LAYOUT
|
||||
? TILE_ASPECT_WIDE
|
||||
: ratio < 1
|
||||
? TILE_ASPECT_TALL
|
||||
: DEFAULT_ASPECT;
|
||||
});
|
||||
return map;
|
||||
}, [aspectRatios, naturalAspectLayout]);
|
||||
|
||||
// A live stream can be shaped differently than detect, so the card follows
|
||||
// whatever is on screen and falls back to detect. Cells stay detect-sized, so
|
||||
// only the card resizes.
|
||||
const [liveAspects, setLiveAspects] = useState<{
|
||||
[key: string]: number | undefined;
|
||||
}>({});
|
||||
|
||||
const liveAspectHandlers = useMemo(() => {
|
||||
const map: { [key: string]: (aspectRatio: number | undefined) => void } =
|
||||
{};
|
||||
cameras.forEach((camera) => {
|
||||
map[camera.name] = (aspectRatio) =>
|
||||
setLiveAspects((prev) =>
|
||||
prev[camera.name] === aspectRatio
|
||||
? prev
|
||||
: { ...prev, [camera.name]: aspectRatio },
|
||||
);
|
||||
});
|
||||
return map;
|
||||
}, [cameras]);
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
@@ -115,7 +210,31 @@ export default function DraggableGridLayout({
|
||||
// grid layout
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||
useUserPersistence<PersistedLayout>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const readPersistedLayout = useCallback(
|
||||
(stored: PersistedLayout | undefined): Layout | undefined => {
|
||||
if (
|
||||
!stored ||
|
||||
stored.version !== LAYOUT_VERSION ||
|
||||
!Array.isArray(stored.layout)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return stored.layout;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Strips per-item `constraints`, which are functions.
|
||||
const toPersisted = useCallback(
|
||||
(layout: Layout): PersistedLayout => ({
|
||||
version: LAYOUT_VERSION,
|
||||
naturalAspect: naturalAspectLayout,
|
||||
layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })),
|
||||
}),
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
@@ -140,11 +259,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
// Keeps the camera-change effect from overwriting the layout we load next.
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
setCurrentNaturalAspect(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@@ -155,21 +274,84 @@ export default function DraggableGridLayout({
|
||||
const [currentGridLayout, setCurrentGridLayout] = useState<
|
||||
Layout | undefined
|
||||
>();
|
||||
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(currentLayout: Layout) => {
|
||||
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
||||
if (
|
||||
!isGridLayoutLoaded ||
|
||||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// save layout to idb
|
||||
setGridLayout(currentLayout);
|
||||
setGridLayout(toPersisted(currentLayout));
|
||||
setShowCircles(true);
|
||||
},
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
[
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
readPersistedLayout,
|
||||
toPersisted,
|
||||
],
|
||||
);
|
||||
|
||||
const dimsFor = useCallback(
|
||||
(name: string) => {
|
||||
const ratio = layoutAspects[name] ?? DEFAULT_ASPECT;
|
||||
const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W;
|
||||
const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio));
|
||||
return { w, h };
|
||||
},
|
||||
[layoutAspects],
|
||||
);
|
||||
|
||||
// Rescale a pre-masonry layout onto the current grid. Both axes scale by a
|
||||
// constant, so tiles the user resized keep their size and their arrangement
|
||||
// stays intact. Only meaningful in bucketed mode, where a tile still has the
|
||||
// shape those coordinates assumed.
|
||||
const convertLegacyLayout = useCallback(
|
||||
(stored: unknown): Layout | undefined => {
|
||||
if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const xScale = GRID_COLS / LEGACY_GRID_COLS;
|
||||
const yScale =
|
||||
Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) /
|
||||
LEGACY_TILE_ROWS;
|
||||
const converted: LayoutItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item.i !== "string" ||
|
||||
typeof item.x !== "number" ||
|
||||
typeof item.y !== "number" ||
|
||||
typeof item.w !== "number" ||
|
||||
typeof item.h !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS);
|
||||
converted.push({
|
||||
i: item.i,
|
||||
x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w),
|
||||
y: Math.max(0, Math.round(item.y * yScale)),
|
||||
w,
|
||||
h: Math.max(1, Math.round(item.h * yScale)),
|
||||
});
|
||||
}
|
||||
|
||||
return converted;
|
||||
},
|
||||
[naturalAspectLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout | undefined) => {
|
||||
(baseLayout: Layout | undefined): Layout | undefined => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
@@ -179,91 +361,92 @@ export default function DraggableGridLayout({
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
const existing: LayoutItem[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
||||
: [];
|
||||
const placed = new Set(existing.map((layout) => layout.i));
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
|
||||
// Start below existing items so new cameras never overlap the user's.
|
||||
const maxBottom = existing.reduce(
|
||||
(max, layout) => Math.max(max, layout.y + layout.h),
|
||||
0,
|
||||
);
|
||||
const colBottoms = new Array(tileColumns).fill(maxBottom);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const result: LayoutItem[] = [...existing];
|
||||
|
||||
cameraNames.forEach((name) => {
|
||||
if (placed.has(name)) {
|
||||
return;
|
||||
}
|
||||
const { w, h } = dimsFor(name);
|
||||
|
||||
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
|
||||
if (w === TILE_BASE_W) {
|
||||
let col = 0;
|
||||
for (let c = 1; c < tileColumns; c++) {
|
||||
if (colBottoms[c] < colBottoms[col]) {
|
||||
col = c;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
i: name,
|
||||
x: col * TILE_BASE_W,
|
||||
y: colBottoms[col],
|
||||
w,
|
||||
h,
|
||||
});
|
||||
colBottoms[col] += h;
|
||||
} 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
|
||||
let pair = 0;
|
||||
for (let c = 1; c + 1 < tileColumns; c++) {
|
||||
if (
|
||||
Math.max(colBottoms[c], colBottoms[c + 1]) <
|
||||
Math.max(colBottoms[pair], colBottoms[pair + 1])
|
||||
) {
|
||||
pair = c;
|
||||
}
|
||||
}
|
||||
const y = Math.max(colBottoms[pair], colBottoms[pair + 1]);
|
||||
result.push({ i: name, x: pair * TILE_BASE_W, y, w, h });
|
||||
colBottoms[pair] = y + h;
|
||||
colBottoms[pair + 1] = y + h;
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
return result;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
const newLayout = generateLayout(undefined);
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = readPersistedLayout(gridLayout);
|
||||
const converted = saved ? undefined : convertLegacyLayout(gridLayout);
|
||||
const base = saved ?? converted;
|
||||
|
||||
if (base) {
|
||||
const updatedLayout = generateLayout(base) ?? base;
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
if (converted || !isEqual(updatedLayout, base)) {
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
setCurrentNaturalAspect(
|
||||
converted ? naturalAspectLayout : gridLayout?.naturalAspect,
|
||||
);
|
||||
} else {
|
||||
// empty or incompatible (pre-masonry) data
|
||||
const newLayout = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(toPersisted(newLayout));
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
}
|
||||
}, [
|
||||
gridLayout,
|
||||
@@ -272,12 +455,15 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
naturalAspectLayout,
|
||||
readPersistedLayout,
|
||||
convertLegacyLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
// Only for camera changes within a loaded group; undefined currentCameras
|
||||
// means the load effect above has not run yet.
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -289,10 +475,10 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
const updatedLayout =
|
||||
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
setGridLayout(toPersisted(updatedLayout));
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
@@ -303,39 +489,47 @@ export default function DraggableGridLayout({
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
const [marginValue, setMarginValue] = useState(16);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNaturalAspectLoaded ||
|
||||
currentNaturalAspect === undefined ||
|
||||
currentNaturalAspect === naturalAspectLayout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculate margin value for browsers that don't have default font size of 16px
|
||||
useLayoutEffect(() => {
|
||||
const calculateRemValue = () => {
|
||||
const htmlElement = document.documentElement;
|
||||
const fontSize = window.getComputedStyle(htmlElement).fontSize;
|
||||
setMarginValue(parseFloat(fontSize));
|
||||
};
|
||||
setCurrentNaturalAspect(naturalAspectLayout);
|
||||
const regenerated = generateLayout(undefined) ?? [];
|
||||
setCurrentGridLayout(regenerated);
|
||||
setGridLayout(toPersisted(regenerated));
|
||||
}, [
|
||||
naturalAspectLayout,
|
||||
isNaturalAspectLoaded,
|
||||
currentNaturalAspect,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
toPersisted,
|
||||
]);
|
||||
|
||||
calculateRemValue();
|
||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Commit-time measure: paints the first frame at the real width (no
|
||||
// innerWidth flash), and the setState re-render is what lets
|
||||
// useResizeObserver see a node mounted after the skeleton swap.
|
||||
const [mountWidth, setMountWidth] = useState<number | null>(null);
|
||||
|
||||
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
||||
gridContainerRef.current = node;
|
||||
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
||||
}, []);
|
||||
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(gridContainerRef);
|
||||
|
||||
const scrollBarWidth = useMemo(() => {
|
||||
if (containerWidth && containerHeight && containerRef.current) {
|
||||
return (
|
||||
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [containerRef, containerHeight, containerWidth]);
|
||||
|
||||
const availableWidth = useMemo(
|
||||
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
||||
[containerWidth, scrollBarWidth],
|
||||
);
|
||||
const availableWidth = containerWidth || mountWidth || 0;
|
||||
|
||||
const hasScrollbar = useMemo(() => {
|
||||
if (containerHeight && containerRef.current) {
|
||||
@@ -346,61 +540,10 @@ export default function DraggableGridLayout({
|
||||
}, [containerRef, containerHeight]);
|
||||
|
||||
const cellHeight = useMemo(() => {
|
||||
const aspectRatio = 16 / 9;
|
||||
// subtract container margin, 1 camera takes up at least 4 rows
|
||||
// account for additional margin on bottom of each row
|
||||
return (
|
||||
((availableWidth ?? window.innerWidth) - 2 * marginValue) /
|
||||
12 /
|
||||
aspectRatio -
|
||||
marginValue +
|
||||
marginValue / 4
|
||||
);
|
||||
}, [availableWidth, marginValue]);
|
||||
|
||||
const handleResize = (
|
||||
_layout: Layout,
|
||||
oldLayoutItem: LayoutItem | null,
|
||||
layoutItem: LayoutItem | null,
|
||||
placeholder: LayoutItem | null,
|
||||
) => {
|
||||
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
||||
|
||||
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
||||
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
||||
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
||||
|
||||
let newWidth, newHeight;
|
||||
|
||||
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
||||
newHeight = Math.round(layoutItem.w / changeCoef);
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
} else {
|
||||
newWidth = Math.round(layoutItem.h * changeCoef);
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
// Ensure dimensions maintain aspect ratio and fit within the grid
|
||||
if (layoutItem.x + newWidth > 12) {
|
||||
newWidth = 12 - layoutItem.x;
|
||||
newHeight = Math.round(newWidth / changeCoef);
|
||||
}
|
||||
|
||||
if (changeCoef == 0.5) {
|
||||
// portrait
|
||||
newHeight = Math.ceil(newHeight / 2) * 2;
|
||||
} else if (changeCoef == 2) {
|
||||
// pano/wide
|
||||
newHeight = Math.ceil(newHeight * 2) / 2;
|
||||
}
|
||||
|
||||
newWidth = Math.round(newHeight * changeCoef);
|
||||
|
||||
layoutItem.w = newWidth;
|
||||
layoutItem.h = newHeight;
|
||||
placeholder.w = layoutItem.w;
|
||||
placeholder.h = layoutItem.h;
|
||||
};
|
||||
const width = availableWidth || window.innerWidth;
|
||||
const columnWidth = width / GRID_COLS;
|
||||
return columnWidth / VERTICAL_RESOLUTION;
|
||||
}, [availableWidth]);
|
||||
|
||||
// audio and stats states
|
||||
|
||||
@@ -503,6 +646,19 @@ export default function DraggableGridLayout({
|
||||
onSaveMuting(true);
|
||||
};
|
||||
|
||||
// RGL's per-item constraint derives height from width, holding each tile at
|
||||
// its camera's aspect while resizing. Constraints are functions, so they live
|
||||
// only on this render copy; toPersisted strips them.
|
||||
const layoutWithConstraints = useMemo(() => {
|
||||
if (!currentGridLayout) {
|
||||
return [] as Layout;
|
||||
}
|
||||
return currentGridLayout.map((item) => ({
|
||||
...item,
|
||||
constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)],
|
||||
}));
|
||||
}, [currentGridLayout, layoutAspects]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -525,8 +681,8 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||
ref={gridContainerRef}
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
||||
ref={attachGridContainer}
|
||||
>
|
||||
<EditGroupDialog
|
||||
open={editGroup}
|
||||
@@ -536,28 +692,36 @@ export default function DraggableGridLayout({
|
||||
/>
|
||||
<Responsive
|
||||
className="grid-layout"
|
||||
width={availableWidth ?? window.innerWidth}
|
||||
width={availableWidth || window.innerWidth}
|
||||
layouts={{
|
||||
lg: currentGridLayout,
|
||||
md: currentGridLayout,
|
||||
sm: currentGridLayout,
|
||||
xs: currentGridLayout,
|
||||
xxs: currentGridLayout,
|
||||
lg: layoutWithConstraints,
|
||||
md: layoutWithConstraints,
|
||||
sm: layoutWithConstraints,
|
||||
xs: layoutWithConstraints,
|
||||
xxs: layoutWithConstraints,
|
||||
}}
|
||||
rowHeight={cellHeight}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
||||
margin={[marginValue, marginValue]}
|
||||
cols={{
|
||||
lg: GRID_COLS,
|
||||
md: GRID_COLS,
|
||||
sm: GRID_COLS,
|
||||
xs: GRID_COLS,
|
||||
xxs: GRID_COLS,
|
||||
}}
|
||||
margin={[0, 0]}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
containerPadding={[0, isEditMode ? 6 : 3]}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||
// se only: top/left handles fight the aspect constraint at a grid
|
||||
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
||||
handles: isEditMode ? ["se"] : [],
|
||||
}}
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
onDragStop={handleLayoutChange}
|
||||
onResize={handleResize}
|
||||
onResizeStart={() => setShowCircles(false)}
|
||||
onResizeStop={handleLayoutChange}
|
||||
>
|
||||
@@ -570,22 +734,12 @@ export default function DraggableGridLayout({
|
||||
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
||||
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
||||
onClick={() => onSelectCamera("birdseye")}
|
||||
>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</BirdseyeLivePlayerGridItem>
|
||||
></BirdseyeLivePlayerGridItem>
|
||||
)}
|
||||
{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 availableStreams = camera.live.streams || {};
|
||||
const firstStreamEntry = Object.values(availableStreams)[0] || "";
|
||||
|
||||
@@ -614,7 +768,14 @@ export default function DraggableGridLayout({
|
||||
?.compatibilityMode || false;
|
||||
return (
|
||||
<GridLiveContextMenu
|
||||
className={grow}
|
||||
className={CARD_FIT}
|
||||
aspectRatio={
|
||||
(naturalAspectLayout
|
||||
? liveAspects[camera.name]
|
||||
: undefined) ??
|
||||
layoutAspects[camera.name] ??
|
||||
DEFAULT_ASPECT
|
||||
}
|
||||
key={camera.name}
|
||||
camera={camera.name}
|
||||
streamName={streamName}
|
||||
@@ -653,8 +814,8 @@ export default function DraggableGridLayout({
|
||||
useWebGL={useWebGL}
|
||||
cameraRef={cameraRef}
|
||||
className={cn(
|
||||
"rounded-lg bg-black md:rounded-2xl",
|
||||
grow,
|
||||
"size-full",
|
||||
naturalAspectLayout ? "bg-background" : "bg-black",
|
||||
isEditMode &&
|
||||
showCircles &&
|
||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
@@ -675,8 +836,8 @@ export default function DraggableGridLayout({
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
||||
/>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</GridLiveContextMenu>
|
||||
);
|
||||
})}
|
||||
@@ -694,6 +855,7 @@ export default function DraggableGridLayout({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-testid="toggle-edit-layout"
|
||||
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
||||
onClick={() =>
|
||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||
@@ -762,17 +924,6 @@ export default function DraggableGridLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function CornerCircles() {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type BirdseyeLivePlayerGridItemProps = {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
@@ -781,6 +932,7 @@ type BirdseyeLivePlayerGridItemProps = {
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
birdseyeConfig: BirdseyeConfig;
|
||||
aspectRatio: number;
|
||||
liveMode: LivePlayerMode;
|
||||
onClick: () => void;
|
||||
};
|
||||
@@ -798,6 +950,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
onTouchEnd,
|
||||
children,
|
||||
birdseyeConfig,
|
||||
aspectRatio: cellAspect,
|
||||
liveMode,
|
||||
onClick,
|
||||
...props
|
||||
@@ -806,7 +959,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
@@ -814,7 +968,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={className}
|
||||
className={cn(CARD_FIT, className)}
|
||||
birdseyeConfig={birdseyeConfig}
|
||||
liveMode={liveMode}
|
||||
onClick={onClick}
|
||||
@@ -829,6 +983,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
type GridLiveContextMenuProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
aspectRatio?: number;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
@@ -860,6 +1015,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
{
|
||||
className,
|
||||
style,
|
||||
aspectRatio: cameraAspect,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onTouchEnd,
|
||||
@@ -887,7 +1043,8 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
style={{ ...style }}
|
||||
className="flex items-center justify-center p-1 [container-type:size]"
|
||||
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseUp={onMouseUp}
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<BirdseyeLivePlayer
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
birdseyeConfig={config.birdseye}
|
||||
liveMode={preferredLiveMode}
|
||||
containerRef={containerRef}
|
||||
|
||||
@@ -860,7 +860,7 @@ export default function LiveCameraView({
|
||||
)}
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
className={`${fullscreen ? "*:rounded-none" : ""}`}
|
||||
className={fullscreen ? "rounded-none" : ""}
|
||||
windowVisible
|
||||
showStillWithoutActivity={false}
|
||||
alwaysShowCameraName={false}
|
||||
|
||||
@@ -410,7 +410,7 @@ export default function LiveDashboardView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] md:p-2"
|
||||
ref={containerRef}
|
||||
>
|
||||
{isMobile && (
|
||||
@@ -609,7 +609,7 @@ export default function LiveDashboardView({
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
key={camera.name}
|
||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||
className={`${grow} bg-black`}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -55,6 +57,7 @@ type SwitchSettingRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
note?: string;
|
||||
checked: boolean | undefined;
|
||||
onCheckedChange: (checked: boolean | undefined) => void;
|
||||
};
|
||||
@@ -63,6 +66,7 @@ function SwitchSettingRow({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
note,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: SwitchSettingRowProps) {
|
||||
@@ -82,6 +86,15 @@ function SwitchSettingRow({
|
||||
</div>
|
||||
</div>
|
||||
<p className={DESCRIPTION_CLASS_NAME}>{description}</p>
|
||||
{note && (
|
||||
<Alert
|
||||
variant="info"
|
||||
className="flex items-center gap-2 p-2 [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-0"
|
||||
>
|
||||
<LuInfo className="size-4 shrink-0" />
|
||||
<AlertDescription className="text-xs">{note}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden w-full md:flex md:max-w-2xl md:items-center">
|
||||
<Switch
|
||||
@@ -321,6 +334,10 @@ export default function UiSettingsView() {
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [naturalAspect, setNaturalAspect] = useUserPersistence(
|
||||
"naturalAspectLayout",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
@@ -351,6 +368,14 @@ export default function UiSettingsView() {
|
||||
checked: cameraNames,
|
||||
onCheckedChange: setCameraName,
|
||||
},
|
||||
{
|
||||
id: "natural-aspect",
|
||||
label: t("general.liveDashboard.naturalAspectLayout.label"),
|
||||
description: t("general.liveDashboard.naturalAspectLayout.desc"),
|
||||
note: t("general.liveDashboard.naturalAspectLayout.descNote"),
|
||||
checked: naturalAspect,
|
||||
onCheckedChange: setNaturalAspect,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -569,6 +594,7 @@ export default function UiSettingsView() {
|
||||
fileName={pendingImport.name}
|
||||
file={pendingImport.file}
|
||||
summary={pendingImport.summary}
|
||||
currentNaturalAspect={naturalAspect ?? false}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user