Miscellaneous fixes (#23358)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions

* improve visibility of blurred icon buttons

* add motion search to history actions menu and mobile drawer

* i18n

* use pure css for motion search dialog video

* defer profile restoration until subscribers are connected

* change order of features in mobile review settings drawer
This commit is contained in:
Josh Hawkins 2026-05-30 22:35:03 -05:00 committed by GitHub
parent 2dd05ca984
commit 08be019bed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 84 additions and 94 deletions

View File

@ -343,13 +343,21 @@ class FrigateApp:
) )
self.dispatcher.profile_manager = self.profile_manager self.dispatcher.profile_manager = self.profile_manager
def restore_active_profile(self) -> None:
"""Re-activate the persisted profile after subscribers are connected.
ZMQ PUB/SUB drops messages with no subscribers, so activation must
run after every config_updater subscriber is up.
"""
if self.profile_manager is None:
return
persisted = ProfileManager.load_persisted_profile() persisted = ProfileManager.load_persisted_profile()
if persisted and any( if persisted and any(
persisted in cam.profiles for cam in self.config.cameras.values() persisted in cam.profiles for cam in self.config.cameras.values()
): ):
logger.info("Restoring persisted profile '%s'", persisted) logger.info("Restoring persisted profile '%s'", persisted)
# don't clear runtime overrides here, restore_runtime_state() later # runtime overrides are layered on top via restore_runtime_state()
# in startup replays it on top of the activated profile
self.profile_manager.activate_profile( self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False persisted, clear_runtime_overrides=False
) )
@ -617,6 +625,7 @@ class FrigateApp:
self.start_watchdog() self.start_watchdog()
# restore persisted runtime overrides on top of config # restore persisted runtime overrides on top of config
self.restore_active_profile()
self.dispatcher.restore_runtime_state() self.dispatcher.restore_runtime_state()
self.init_auth() self.init_auth()

View File

@ -67,7 +67,7 @@
"needsReview": "Needs review", "needsReview": "Needs review",
"securityConcern": "Security concern", "securityConcern": "Security concern",
"motionSearch": { "motionSearch": {
"menuItem": "Motion search", "menuItem": "Motion Search",
"openMenu": "Camera options" "openMenu": "Camera options"
}, },
"motionPreviews": { "motionPreviews": {

View File

@ -14,8 +14,8 @@ const BlurredIconButton = forwardRef<HTMLDivElement, BlurredIconButtonProps>(
)} )}
{...rest} {...rest}
> >
<div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-0 blur-sm transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" /> <div className="pointer-events-none absolute inset-0 m-auto size-5 scale-95 rounded-full bg-black opacity-30 blur-md transition-all duration-200 group-hover:scale-100 group-hover:opacity-100 group-hover:blur-xl" />
<div className="relative z-10 cursor-pointer text-white/85 hover:text-white"> <div className="relative z-10 cursor-pointer text-white/85 drop-shadow-[0_1px_1px_rgba(0,0,0,0.9)] hover:text-white">
{children} {children}
</div> </div>
</div> </div>

View File

@ -12,14 +12,21 @@ type ActionsDropdownProps = {
onDebugReplayClick?: () => void; onDebugReplayClick?: () => void;
onExportClick: () => void; onExportClick: () => void;
onShareTimestampClick: () => void; onShareTimestampClick: () => void;
onMotionSearchClick?: () => void;
}; };
export default function ActionsDropdown({ export default function ActionsDropdown({
onDebugReplayClick, onDebugReplayClick,
onExportClick, onExportClick,
onShareTimestampClick, onShareTimestampClick,
onMotionSearchClick,
}: Readonly<ActionsDropdownProps>) { }: Readonly<ActionsDropdownProps>) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]); const { t } = useTranslation([
"components/dialog",
"views/replay",
"views/events",
"common",
]);
return ( return (
<DropdownMenu> <DropdownMenu>
@ -42,6 +49,11 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}> <DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })} {t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem> </DropdownMenuItem>
{onMotionSearchClick && (
<DropdownMenuItem onClick={onMotionSearchClick}>
{t("motionSearch.menuItem", { ns: "views/events" })}
</DropdownMenuItem>
)}
{onDebugReplayClick && ( {onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}> <DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })} {t("title", { ns: "views/replay" })}

View File

@ -3,7 +3,7 @@ import { baseUrl } from "@/api/baseUrl";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug, LuShare2 } from "react-icons/lu"; import { LuBug, LuSearch, LuShare2 } from "react-icons/lu";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog"; import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import { import {
@ -46,6 +46,7 @@ const DRAWER_FEATURES = [
"filter", "filter",
"debug-replay", "debug-replay",
"share-timestamp", "share-timestamp",
"motion-search",
] as const; ] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number]; export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
@ -54,6 +55,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"filter", "filter",
"debug-replay", "debug-replay",
"share-timestamp", "share-timestamp",
"motion-search",
]; ];
type MobileReviewSettingsDrawerProps = { type MobileReviewSettingsDrawerProps = {
@ -75,6 +77,7 @@ type MobileReviewSettingsDrawerProps = {
setDebugReplayMode?: (mode: ExportMode) => void; setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void; setDebugReplayRange?: (range: TimeRange | undefined) => void;
onShareTimestamp?: (timestamp: number) => void; onShareTimestamp?: (timestamp: number) => void;
onMotionSearch?: () => void;
onUpdateFilter: (filter: ReviewFilter) => void; onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
@ -99,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
setDebugReplayMode = () => {}, setDebugReplayMode = () => {},
setDebugReplayRange = () => {}, setDebugReplayRange = () => {},
onShareTimestamp = () => {}, onShareTimestamp = () => {},
onMotionSearch,
onUpdateFilter, onUpdateFilter,
setRange, setRange,
setMode, setMode,
@ -108,6 +112,7 @@ export default function MobileReviewSettingsDrawer({
"views/recording", "views/recording",
"components/dialog", "components/dialog",
"views/replay", "views/replay",
"views/events",
"common", "common",
]); ]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@ -343,27 +348,6 @@ export default function MobileReviewSettingsDrawer({
{t("export")} {t("export")}
</Button> </Button>
)} )}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("calendar") && ( {features.includes("calendar") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
@ -390,6 +374,40 @@ export default function MobileReviewSettingsDrawer({
{t("filter")} {t("filter")}
</Button> </Button>
)} )}
{features.includes("share-timestamp") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
onClick={() => {
const initialTimestamp = Math.floor(currentTime);
setShareTimestampAtOpen(initialTimestamp);
setCustomShareTimestamp(initialTimestamp);
setSelectedShareOption("current");
setDrawerMode("share-timestamp");
}}
>
<LuShare2 className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("recording.shareTimestamp.label", {
ns: "components/dialog",
})}
</Button>
)}
{features.includes("motion-search") && onMotionSearch && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("motionSearch.menuItem", { ns: "views/events" })}
onClick={() => {
onMotionSearch();
setDrawerMode("none");
}}
>
<LuSearch className="size-5 rounded-md bg-secondary-foreground stroke-secondary p-1" />
{t("motionSearch.menuItem", { ns: "views/events" })}
</Button>
)}
{isAdmin && features.includes("debug-replay") && ( {isAdmin && features.includes("debug-replay") && (
<Button <Button
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"

View File

@ -56,11 +56,9 @@ export default function Events() {
false, false,
); );
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>( const [recording, setRecording] = useOverlayState<
"recording", RecordingStartingPoint | undefined
undefined, >("recording", undefined, false);
false,
);
const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState< const [motionPreviewsCamera, setMotionPreviewsCamera] = useOverlayState<
string | undefined string | undefined
>("motionPreviewsCamera", undefined); >("motionPreviewsCamera", undefined);
@ -668,6 +666,10 @@ export default function Events() {
filter={reviewFilter} filter={reviewFilter}
updateFilter={onUpdateFilter} updateFilter={onUpdateFilter}
refreshData={reloadData} refreshData={reloadData}
onMotionSearch={(camera) => {
setMotionSearchCamera(camera);
setRecording(undefined);
}}
/> />
); );
} }

View File

@ -112,35 +112,8 @@ export default function MotionSearchDialog({
}: MotionSearchDialogProps) { }: MotionSearchDialogProps) {
const { t } = useTranslation(["views/motionSearch", "common"]); const { t } = useTranslation(["views/motionSearch", "common"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null,
);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const containerWidth = containerSize.width;
const containerHeight = containerSize.height;
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (!containerNode) {
return;
}
const measure = () => {
const rect = containerNode.getBoundingClientRect();
setContainerSize((prev) =>
prev.width === rect.width && prev.height === rect.height
? prev
: { width: rect.width, height: rect.height },
);
};
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(containerNode);
return () => observer.disconnect();
}, [containerNode]);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (!selectedCamera) return undefined; if (!selectedCamera) return undefined;
return config.cameras[selectedCamera]; return config.cameras[selectedCamera];
@ -169,28 +142,6 @@ export default function MotionSearchDialog({
setIsDrawingROI(true); setIsDrawingROI(true);
}, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]); }, [isSearching, polygonPoints.length, setIsDrawingROI, setPolygonPoints]);
const imageSize = useMemo(() => {
if (!containerWidth || !containerHeight || !cameraConfig) {
return { width: 0, height: 0 };
}
const cameraAspectRatio =
cameraConfig.detect.width / cameraConfig.detect.height;
const availableAspectRatio = containerWidth / containerHeight;
if (availableAspectRatio >= cameraAspectRatio) {
return {
width: containerHeight * cameraAspectRatio,
height: containerHeight,
};
}
return {
width: containerWidth,
height: containerWidth / cameraAspectRatio,
};
}, [containerWidth, containerHeight, cameraConfig]);
useEffect(() => { useEffect(() => {
setImageLoaded(false); setImageLoaded(false);
}, [selectedCamera]); }, [selectedCamera]);
@ -280,19 +231,9 @@ export default function MotionSearchDialog({
height: "100%", height: "100%",
}} }}
> >
<div <div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary">
ref={setContainerNode}
className="relative flex w-full items-center justify-center overflow-hidden rounded-lg border bg-secondary"
style={{ aspectRatio: "16 / 9" }}
>
{selectedCamera && cameraConfig ? ( {selectedCamera && cameraConfig ? (
<div <div className="relative h-full w-full">
className="relative"
style={{
width: imageSize.width || "100%",
height: imageSize.height || "100%",
}}
>
<img <img
alt={t("dialog.previewAlt", { alt={t("dialog.previewAlt", {
camera: selectedCamera, camera: selectedCamera,

View File

@ -95,6 +95,7 @@ type RecordingViewProps = {
filter?: ReviewFilter; filter?: ReviewFilter;
updateFilter: (newFilter: ReviewFilter) => void; updateFilter: (newFilter: ReviewFilter) => void;
refreshData?: () => void; refreshData?: () => void;
onMotionSearch?: (camera: string) => void;
}; };
export function RecordingView({ export function RecordingView({
startCamera, startCamera,
@ -107,6 +108,7 @@ export function RecordingView({
filter, filter,
updateFilter, updateFilter,
refreshData, refreshData,
onMotionSearch,
}: RecordingViewProps) { }: RecordingViewProps) {
const { t } = useTranslation(["views/events", "components/dialog"]); const { t } = useTranslation(["views/events", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -725,6 +727,9 @@ export function RecordingView({
setCustomShareTimestamp(initialTimestamp); setCustomShareTimestamp(initialTimestamp);
setShareTimestampOpen(true); setShareTimestampOpen(true);
}} }}
onMotionSearchClick={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onDebugReplayClick={ onDebugReplayClick={
isAdmin isAdmin
? () => { ? () => {
@ -807,6 +812,9 @@ export function RecordingView({
} }
}} }}
onShareTimestamp={onShareReviewLink} onShareTimestamp={onShareReviewLink}
onMotionSearch={
onMotionSearch ? () => onMotionSearch(mainCamera) : undefined
}
onUpdateFilter={updateFilter} onUpdateFilter={updateFilter}
setRange={setExportRange} setRange={setExportRange}
setMode={setExportMode} setMode={setExportMode}