Improve camera group live view grids (#24461)

* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
This commit is contained in:
Josh Hawkins
2026-09-27 17:09:31 -06:00
committed by GitHub
parent 6791df7971
commit 0977fa5e15
20 changed files with 1699 additions and 331 deletions
+1 -1
View File
@@ -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,12 @@ 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 { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
import { isMobileOnly } from "react-device-detect";
import FilterSwitch from "@/components/filter/FilterSwitch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
importedLayoutsNaturalAspect,
ImportSummary,
TransferSection,
UiSettingsFile,
@@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = {
fileName: string;
file: UiSettingsFile;
summary: ImportSummary;
currentNaturalAspect: boolean;
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
};
@@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({
fileName,
file,
summary,
currentNaturalAspect,
onConfirm,
}: ImportUiSettingsDialogProps) {
const { t } = useTranslation(["views/settings", "common"]);
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
const available = useMemo(
() => ({
layouts: summary.layoutGroupCount > 0,
// phones use the static grid, so a saved grid layout has nothing to
// apply to and would only flip the tile sizing mode behind the scenes
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
streaming: summary.streamingCameraCount > 0,
preferences: summary.preferenceCount > 0,
}),
@@ -90,6 +96,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);
@@ -113,73 +129,102 @@ export default function ImportUiSettingsDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-0.5">
<p className="break-all text-base text-primary-variant">{fileName}</p>
<p className="text-sm text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
<div className="min-w-0">
<p className="break-all text-base font-medium text-primary-variant">
{fileName}
</p>
<p className="text-xs text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
</div>
<div className="space-y-3">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
<div className="space-y-2.5">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
{(visibleUnknownGroups.length > 0 ||
visibleUnknownCameras.length > 0) && (
<Alert variant="warning">
<LuTriangleAlert className="size-5" />
<AlertDescription className="space-y-2">
{visibleUnknownGroups.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownGroups", {
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "),
})}
</p>
)}
{visibleUnknownCameras.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownCameras", {
count: visibleUnknownCameras.length,
cameras: visibleUnknownCameras.join(", "),
})}
</p>
)}
</AlertDescription>
</Alert>
)}
{isMobileOnly && summary.layoutGroupCount > 0 && (
<Alert variant="info">
<LuInfo className="size-5" />
<AlertDescription>
{t("general.backupRestore.importDialog.layoutsPhone")}
</AlertDescription>
</Alert>
)}
{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">
<LuTriangleAlert className="size-5" />
<AlertDescription className="space-y-2">
{visibleUnknownGroups.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownGroups", {
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "),
})}
</p>
)}
{visibleUnknownCameras.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownCameras", {
count: visibleUnknownCameras.length,
cameras: visibleUnknownCameras.join(", "),
})}
</p>
)}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
@@ -198,7 +243,7 @@ export default function ImportUiSettingsDialog({
>
{isImporting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<ActivityIndicator className="size-4" />
<span>{t("general.backupRestore.importDialog.confirm")}</span>
</div>
) : (
@@ -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,31 @@ 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",
"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>
+56 -14
View File
@@ -54,6 +54,7 @@ type LivePlayerProps = {
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
};
export default function LivePlayer({
@@ -80,6 +81,7 @@ export default function LivePlayer({
onError,
onMicrophoneError,
onResetLiveMode,
onLiveAspectChange,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
@@ -127,6 +129,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 +295,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 +316,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 +326,7 @@ export default function LivePlayer({
setStats={setStats}
onPlaying={playerIsPlaying}
pip={pip}
setFullResolution={setFullResolution}
setFullResolution={handleFullResolution}
onError={onError}
/>
);
@@ -308,7 +342,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 +359,9 @@ export default function LivePlayer({
player = null;
}
} else {
player = <ActivityIndicator />;
player = (
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
);
}
return (
@@ -340,10 +376,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,10 +395,14 @@ 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 &&
@@ -368,7 +410,7 @@ export default function LivePlayer({
(!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 +489,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 +533,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?.();
};