fix object selection and time updating

This commit is contained in:
Josh Hawkins 2025-10-31 09:55:31 -05:00
parent 24ef3f213c
commit 7843398a1b
3 changed files with 220 additions and 290 deletions

View File

@ -31,10 +31,9 @@ import {
FaDownload, FaDownload,
FaHistory, FaHistory,
FaImage, FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa"; } from "react-icons/fa";
import TrackingDetails from "./TrackingDetails"; import { TrackingDetails } from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -80,13 +79,9 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi"; import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = [ const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
"details",
"snapshot",
"video",
"tracking_details",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -150,16 +145,6 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; const views = [...SEARCH_TABS];
if (!search.has_snapshot) {
const index = views.indexOf("snapshot");
views.splice(index, 1);
}
if (!search.has_clip) {
const index = views.indexOf("video");
views.splice(index, 1);
}
if (search.data.type != "object" || !search.has_clip) { if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details"); const index = views.indexOf("tracking_details");
views.splice(index, 1); views.splice(index, 1);
@ -174,7 +159,7 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setSearchPage("details"); setSearchPage("snapshot");
} }
}, [pageToggle, searchTabs, setSearchPage]); }, [pageToggle, searchTabs, setSearchPage]);
@ -196,16 +181,20 @@ export default function SearchDetailDialog({
{Object.values(searchTabs).map((item) => ( {Object.values(searchTabs).map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item} data-nav-item={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />} {item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />} {item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">{t(`type.${item}`)}</div> <div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
@ -227,186 +216,191 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<Overlay <DetailStreamProvider
open={isOpen} isDetailMode={true}
onOpenChange={handleOpenChange} currentTime={(search as unknown as Event)?.start_time ?? 0}
enableHistoryBack={true} camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
> >
<Content <Overlay
className={cn( open={isOpen}
"scrollbar-container overflow-y-auto", onOpenChange={handleOpenChange}
isDesktop && enableHistoryBack={true}
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4",
)}
> >
<Header> <Content
<Title>{t("trackedObjectDetails")}</Title> className={cn(
<Description className="sr-only"> "scrollbar-container overflow-y-auto",
{t("trackedObjectDetails")} isDesktop &&
</Description> "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
</Header> isDesktop &&
{isDesktop ? ( page == "tracking_details" &&
page === "tracking_details" ? ( "lg:max-w-[75%] xl:max-w-[80%]",
<TrackingDetails isMobile && "px-4",
className="size-full" )}
event={search as unknown as Event} >
tabs={tabsComponent} <Header>
/> <Title>{t("trackedObjectDetails")}</Title>
) : ( <Description className="sr-only">
<div className="flex h-full gap-4 overflow-hidden"> {t("trackedObjectDetails")}
<div className="scrollbar-container flex-[3] overflow-y-hidden"> </Description>
{page === "snapshot" && search.has_snapshot && ( </Header>
<ObjectSnapshotTab {isDesktop ? (
search={ page === "tracking_details" ? (
{ <TrackingDetails
...search, className="size-full"
plus_id: config?.plus?.enabled event={search as unknown as Event}
? search.plus_id tabs={tabsComponent}
: "not_enabled", />
} as unknown as Event ) : (
} <div className="flex h-full gap-4 overflow-hidden">
onEventUploaded={() => { <div
search.plus_id = "new_upload"; className={cn(
}} "scrollbar-container flex-[3] overflow-y-hidden",
/> page === "snapshot" && !search.has_snapshot && "flex-[2]",
)} )}
{page === "video" && search.has_clip && ( >
<VideoTab search={search} /> {page === "snapshot" && search.has_snapshot && (
)} <ObjectSnapshotTab
{(page === "details" || search={
(!search.has_snapshot && page === "snapshot") || {
(!search.has_clip && page === "video")) && ( ...search,
<img plus_id: config?.plus?.enabled
className="aspect-video select-none rounded-lg object-contain transition-opacity" ? search.plus_id
style={ : "not_enabled",
isIOS } as unknown as Event
? { }
WebkitUserSelect: "none", onEventUploaded={() => {
WebkitTouchCallout: "none", search.plus_id = "new_upload";
} }}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/> />
)} )}
{page == "snapshot" && ( {page === "snapshot" && !search.has_snapshot && (
<ObjectDetailsTab <img
search={search} className="size-full select-none rounded-lg object-contain transition-opacity"
config={config} style={
setSearch={setSearch} isIOS
setSimilarity={setSimilarity} ? {
setInputFocused={setInputFocused} WebkitUserSelect: "none",
showThumbnail={false} WebkitTouchCallout: "none",
/> }
)} : undefined
{page == "video" && ( }
<ObjectDetailsTab draggable={false}
search={search} src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/> />
)} )}
</div> </div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
</div>
</div>
</div> </div>
</div> )
) ) : (
) : ( <>
<> <ScrollArea
<ScrollArea className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} >
> <div className="flex flex-row">
<div className="flex flex-row"> <ToggleGroup
<ToggleGroup className="*:rounded-md *:px-3 *:py-4"
className="*:rounded-md *:px-3 *:py-4" type="single"
type="single" size="sm"
size="sm" value={pageToggle}
value={pageToggle} onValueChange={(value: SearchTab) => {
onValueChange={(value: SearchTab) => { if (value) {
if (value) { setPageToggle(value);
setPageToggle(value); }
} }}
}} >
> {Object.values(searchTabs).map((item) => (
{Object.values(searchTabs).map((item) => ( <ToggleGroupItem
<ToggleGroupItem key={item}
key={item} className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`} value={item}
value={item} data-nav-item={item}
data-nav-item={item} aria-label={`Select ${item}`}
aria-label={`Select ${item}`} >
> {item == "snapshot" && <FaImage className="size-4" />}
{item == "details" && <FaRegListAlt className="size-4" />} {item == "tracking_details" && (
{item == "snapshot" && <FaImage className="size-4" />} <PiPath className="size-4" />
{item == "video" && <FaVideo className="size-4" />} )}
{item == "tracking_details" && ( <div className="smart-capitalize">
<PiPath className="size-4" /> {t(`type.${item}`)}
)} </div>
<div className="smart-capitalize"> </ToggleGroupItem>
{t(`type.${item}`)} ))}
</div> </ToggleGroup>
</ToggleGroupItem> <ScrollBar orientation="horizontal" className="h-0" />
))} </div>
</ToggleGroup> </ScrollArea>
<ScrollBar orientation="horizontal" className="h-0" /> {page == "snapshot" && (
</div> <>
</ScrollArea> {search.has_snapshot && (
{page == "details" && ( <ObjectSnapshotTab
<ObjectDetailsTab search={
search={search} {
config={config} ...search,
setSearch={setSearch} plus_id: config?.plus?.enabled
setSimilarity={setSimilarity} ? search.plus_id
setInputFocused={setInputFocused} : "not_enabled",
/> } as unknown as Event
)} }
{page == "snapshot" && ( onEventUploaded={() => {
<ObjectSnapshotTab search.plus_id = "new_upload";
search={ }}
{ />
...search, )}
plus_id: config?.plus?.enabled {page == "snapshot" && !search.has_snapshot && (
? search.plus_id <img
: "not_enabled", className="w-full select-none rounded-lg object-contain transition-opacity"
} as unknown as Event style={
} isIOS
onEventUploaded={() => { ? {
search.plus_id = "new_upload"; WebkitUserSelect: "none",
}} WebkitTouchCallout: "none",
/> }
)} : undefined
{page == "video" && <VideoTab search={search} />} }
{page == "tracking_details" && ( draggable={false}
<TrackingDetails src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
className="w-full overflow-x-hidden" />
event={search as unknown as Event} )}
/> <Heading as="h3" className="mt-2 smart-capitalize">
)} {t("type.details")}
</> </Heading>
)} <ObjectDetailsTab
</Content> search={search}
</Overlay> config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && (
<TrackingDetails
className="w-full overflow-x-hidden"
event={search as unknown as Event}
/>
)}
</>
)}
</Content>
</Overlay>
</DetailStreamProvider>
); );
} }
@ -1305,7 +1299,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && ( search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2"> <Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex flex-col space-y-3")}> <div className={cn("flex max-w-sm flex-col space-y-3")}>
<div className={"text-lg leading-none"}> <div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")} {t("explore.plus.submitToPlus.label")}
</div> </div>
@ -1314,7 +1308,7 @@ export function ObjectSnapshotTab({
</div> </div>
</div> </div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end"> <div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
<div> <div>

View File

@ -8,20 +8,7 @@ import Heading from "@/components/ui/heading";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { import { LuCircle, LuSettings } from "react-icons/lu";
LuCircle,
LuCircleDot,
LuEar,
LuPlay,
LuSettings,
LuTruck,
} from "react-icons/lu";
import { IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Tooltip, Tooltip,
@ -43,17 +30,13 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { useDetailStream } from "@/context/detail-stream-context";
DetailStreamProvider,
useDetailStream,
} from "@/context/detail-stream-context";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
type TrackingDetailsProps = { type TrackingDetailsProps = {
@ -63,36 +46,16 @@ type TrackingDetailsProps = {
tabs?: React.ReactNode; tabs?: React.ReactNode;
}; };
// Wrapper component that provides DetailStreamContext export function TrackingDetails({
export default function TrackingDetails(props: TrackingDetailsProps) {
const [currentTime, setCurrentTime] = useState(props.event.start_time ?? 0);
return (
<DetailStreamProvider
isDetailMode={true}
currentTime={currentTime}
camera={props.event.camera}
>
<TrackingDetailsInner {...props} onTimeUpdate={setCurrentTime} />
</DetailStreamProvider>
);
}
// Inner component with access to DetailStreamContext
function TrackingDetailsInner({
className, className,
event, event,
tabs, tabs,
onTimeUpdate, }: TrackingDetailsProps) {
}: TrackingDetailsProps & { onTimeUpdate: (time: number) => void }) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const { const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
setSelectedObjectIds, useDetailStream();
annotationOffset, const [currentTime, setCurrentTime] = useState(event.start_time ?? 0);
setAnnotationOffset,
currentTime,
} = useDetailStream();
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([ const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
"timeline", "timeline",
@ -110,7 +73,7 @@ function TrackingDetailsInner({
); );
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [_selectedZone, _setSelectedZone] = useState(""); const [_selectedZone, setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]); const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true); const [showZones, setShowZones] = useState(true);
@ -151,7 +114,7 @@ function TrackingDetailsInner({
const handleLifecycleClick = useCallback((item: TrackingDetailsSequence) => { const handleLifecycleClick = useCallback((item: TrackingDetailsSequence) => {
const timestamp = item.timestamp ?? 0; const timestamp = item.timestamp ?? 0;
setLifecycleZones(item.data.zones); setLifecycleZones(item.data.zones);
_setSelectedZone(""); setSelectedZone("");
// Set the target timestamp to seek to // Set the target timestamp to seek to
setSeekToTimestamp(timestamp); setSeekToTimestamp(timestamp);
@ -287,8 +250,6 @@ function TrackingDetailsInner({
} }
}, [aspectRatio]); }, [aspectRatio]);
// Container layout classes - no longer needed, handled in return JSX
const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => { const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => {
// Set the target timestamp to seek to // Set the target timestamp to seek to
setSeekToTimestamp(timestamp); setSeekToTimestamp(timestamp);
@ -296,16 +257,16 @@ function TrackingDetailsInner({
const handleTimeUpdate = useCallback( const handleTimeUpdate = useCallback(
(time: number) => { (time: number) => {
// Convert video time to absolute timestamp
const absoluteTime = time - REVIEW_PADDING + event.start_time; const absoluteTime = time - REVIEW_PADDING + event.start_time;
onTimeUpdate(absoluteTime); setCurrentTime(absoluteTime);
}, },
[event.start_time, onTimeUpdate], [event.start_time],
); );
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
return ( return (
<div <div
className={cn( className={cn(
@ -337,7 +298,7 @@ function TrackingDetailsInner({
hotKeys={false} hotKeys={false}
supportsFullscreen={false} supportsFullscreen={false}
fullscreen={false} fullscreen={false}
frigateControls={false} frigateControls={true}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime} onSeekToTime={handleSeekToTime}
isDetailMode={true} isDetailMode={true}
@ -534,8 +495,10 @@ function TrackingDetailsInner({
areaPx={areaPx} areaPx={areaPx}
areaPct={areaPct} areaPct={areaPct}
onClick={() => handleLifecycleClick(item)} onClick={() => handleLifecycleClick(item)}
setSelectedZone={_setSelectedZone} setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor} getZoneColor={getZoneColor}
effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange}
/> />
); );
})} })}
@ -551,43 +514,6 @@ function TrackingDetailsInner({
); );
} }
type GetTimelineIconParams = {
lifecycleItem: TrackingDetailsSequence;
className?: string;
};
export function LifecycleIcon({
lifecycleItem,
className,
}: GetTimelineIconParams) {
switch (lifecycleItem.class_type) {
case "visible":
return <LuPlay className={cn("size-5", className)} />;
case "gone":
return <IoMdExit className={cn("size-5", className)} />;
case "active":
return <LuCircleDot className={cn("size-5", className)} />;
case "stationary":
return <LuCircle className={cn("size-5", className)} />;
case "entered_zone":
return <MdOutlineLocationOn className={cn("size-5", className)} />;
case "attribute":
return lifecycleItem.data.attribute === "face" ? (
<MdFaceUnlock className={cn("size-5", className)} />
) : lifecycleItem.data.attribute === "license_plate" ? (
<MdOutlinePictureInPictureAlt className={cn("size-5", className)} />
) : (
<LuTruck className={cn("size-5", className)} />
);
case "heard":
return <LuEar className={cn("size-5", className)} />;
case "external":
return <IoPlayCircleOutline className={cn("size-5", className)} />;
default:
return null;
}
}
type LifecycleIconRowProps = { type LifecycleIconRowProps = {
item: TrackingDetailsSequence; item: TrackingDetailsSequence;
isActive?: boolean; isActive?: boolean;
@ -598,6 +524,8 @@ type LifecycleIconRowProps = {
onClick: () => void; onClick: () => void;
setSelectedZone: (z: string) => void; setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined; getZoneColor: (zoneName: string) => number[] | undefined;
effectiveTime?: number;
isTimelineActive?: boolean;
}; };
function LifecycleIconRow({ function LifecycleIconRow({
@ -610,6 +538,8 @@ function LifecycleIconRow({
onClick, onClick,
setSelectedZone, setSelectedZone,
getZoneColor, getZoneColor,
effectiveTime,
isTimelineActive,
}: LifecycleIconRowProps) { }: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore", "components/player"]); const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -632,7 +562,9 @@ function LifecycleIconRow({
<LuCircle <LuCircle
className={cn( className={cn(
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none", "relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
isActive && "fill-selected duration-300", (isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) &&
isTimelineActive &&
"fill-selected duration-300",
)} )}
/> />
</div> </div>

View File

@ -22,6 +22,7 @@ interface DetailStreamProviderProps {
isDetailMode: boolean; isDetailMode: boolean;
currentTime: number; currentTime: number;
camera: string; camera: string;
initialSelectedObjectIds?: string[];
} }
export function DetailStreamProvider({ export function DetailStreamProvider({
@ -29,8 +30,11 @@ export function DetailStreamProvider({
isDetailMode, isDetailMode,
currentTime, currentTime,
camera, camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) { }: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]); const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>(
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {