mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-14 11:02:08 +03:00
new activity stream panel in history view
This commit is contained in:
parent
ada15e1686
commit
be0b0cfd72
@ -7,6 +7,7 @@ import PreviewPlayer, {
|
|||||||
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
|
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
|
||||||
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
|
import ActivityStream from "@/components/timeline/ActivityStream";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
@ -40,7 +41,11 @@ import { IoMdArrowRoundBack } from "react-icons/io";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { TimeRange, TimelineType } from "@/types/timeline";
|
import {
|
||||||
|
TimeRange,
|
||||||
|
TimelineType,
|
||||||
|
ObjectLifecycleSequence,
|
||||||
|
} from "@/types/timeline";
|
||||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||||
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer";
|
||||||
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer";
|
||||||
@ -66,6 +71,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
import { ActivityStreamProvider } from "@/contexts/ActivityStreamContext";
|
||||||
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
||||||
|
|
||||||
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
||||||
@ -159,6 +165,46 @@ export function RecordingView({
|
|||||||
chunkedTimeRange[chunkedTimeRange.length - 1],
|
chunkedTimeRange[chunkedTimeRange.length - 1],
|
||||||
[selectedRangeIdx, chunkedTimeRange],
|
[selectedRangeIdx, chunkedTimeRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// timeline data for activity stream
|
||||||
|
const { data: timelineResponse } = useSWR<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
count: number;
|
||||||
|
hours: { [key: string]: ObjectLifecycleSequence[] };
|
||||||
|
}>([
|
||||||
|
"timeline/hourly",
|
||||||
|
{
|
||||||
|
cameras: mainCamera,
|
||||||
|
before: timeRange.before,
|
||||||
|
after: timeRange.after,
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timelineData = useMemo(() => {
|
||||||
|
if (!timelineResponse?.hours) return [];
|
||||||
|
let data = Object.values(timelineResponse.hours).flat();
|
||||||
|
|
||||||
|
// Filter by review filter
|
||||||
|
if (filter?.labels && filter.labels.length > 0) {
|
||||||
|
data = data.filter((item) =>
|
||||||
|
filter.labels!.includes(item.data.label.replace("-verified", "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.zones && filter.zones.length > 0) {
|
||||||
|
data = data.filter(
|
||||||
|
(item) =>
|
||||||
|
item.data.zones &&
|
||||||
|
Array.isArray(item.data.zones) &&
|
||||||
|
item.data.zones.some((zone) => filter.zones!.includes(zone)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, [timelineResponse, filter]);
|
||||||
|
|
||||||
const reviewFilterList = useMemo(() => {
|
const reviewFilterList = useMemo(() => {
|
||||||
const uniqueLabels = new Set<string>();
|
const uniqueLabels = new Set<string>();
|
||||||
|
|
||||||
@ -521,202 +567,223 @@ export function RecordingView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="flex size-full flex-col pt-2">
|
<ActivityStreamProvider
|
||||||
<Toaster closeButton={true} />
|
isActivityMode={timelineType === "activity"}
|
||||||
<div className="relative mb-2 flex h-11 w-full items-center justify-between px-2">
|
currentTime={currentTime}
|
||||||
{isMobile && (
|
camera={mainCamera}
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
>
|
||||||
)}
|
<div ref={contentRef} className="flex size-full flex-col pt-2">
|
||||||
<div className={cn("flex items-center gap-2")}>
|
<Toaster closeButton={true} />
|
||||||
<Button
|
<div className="relative mb-2 flex h-11 w-full items-center justify-between px-2">
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
{isMobile && (
|
||||||
aria-label={t("label.back", { ns: "common" })}
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => navigate(-1)}
|
<div className={cn("flex items-center gap-2")}>
|
||||||
>
|
<Button
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label="Go to the main camera live view"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/#${mainCamera}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaVideo className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("menu.live.title", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<MobileCameraDrawer
|
||||||
|
allCameras={effectiveCameras}
|
||||||
|
selected={mainCamera}
|
||||||
|
onSelectCamera={onSelectCamera}
|
||||||
|
/>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<div className="text-primary">
|
<ExportDialog
|
||||||
{t("button.back", { ns: "common" })}
|
camera={mainCamera}
|
||||||
</div>
|
currentTime={currentTime}
|
||||||
|
latestTime={timeRange.before}
|
||||||
|
mode={exportMode}
|
||||||
|
range={exportRange}
|
||||||
|
showPreview={showExportPreview}
|
||||||
|
setRange={(range) => {
|
||||||
|
setExportRange(range);
|
||||||
|
|
||||||
|
if (range != undefined) {
|
||||||
|
mainControllerRef.current?.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
setMode={setExportMode}
|
||||||
|
setShowPreview={setShowExportPreview}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
|
||||||
aria-label="Go to the main camera live view"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/#${mainCamera}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaVideo className="size-5 text-secondary-foreground" />
|
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<div className="text-primary">
|
<ReviewFilterGroup
|
||||||
{t("menu.live.title", { ns: "common" })}
|
filters={["cameras", "date", "general"]}
|
||||||
</div>
|
reviewSummary={reviewSummary}
|
||||||
|
recordingsSummary={recordingsSummary}
|
||||||
|
filter={filter}
|
||||||
|
motionOnly={false}
|
||||||
|
filterList={reviewFilterList}
|
||||||
|
showReviewed
|
||||||
|
setShowReviewed={() => {}}
|
||||||
|
mainCamera={mainCamera}
|
||||||
|
onUpdateFilter={(newFilter: ReviewFilter) => {
|
||||||
|
const updatedCameras =
|
||||||
|
newFilter.cameras === undefined
|
||||||
|
? undefined // Respect undefined as "all cameras"
|
||||||
|
: newFilter.cameras
|
||||||
|
? Array.from(
|
||||||
|
new Set([mainCamera, ...(newFilter.cameras || [])]),
|
||||||
|
) // Include mainCamera if specific cameras are selected
|
||||||
|
: [mainCamera];
|
||||||
|
const adjustedFilter: ReviewFilter = {
|
||||||
|
...newFilter,
|
||||||
|
cameras: updatedCameras,
|
||||||
|
};
|
||||||
|
updateFilter(adjustedFilter);
|
||||||
|
}}
|
||||||
|
setMotionOnly={() => {}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
{isDesktop ? (
|
||||||
</div>
|
<ToggleGroup
|
||||||
<div className="flex items-center justify-end gap-2">
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
<MobileCameraDrawer
|
type="single"
|
||||||
allCameras={effectiveCameras}
|
size="sm"
|
||||||
selected={mainCamera}
|
value={timelineType}
|
||||||
onSelectCamera={onSelectCamera}
|
onValueChange={(value: TimelineType) =>
|
||||||
/>
|
value ? setTimelineType(value, true) : null
|
||||||
{isDesktop && (
|
} // don't allow the severity to be unselected
|
||||||
<ExportDialog
|
>
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
|
||||||
|
value="timeline"
|
||||||
|
aria-label={t("timeline.aria")}
|
||||||
|
>
|
||||||
|
<div className="">{t("timeline")}</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
|
||||||
|
value="events"
|
||||||
|
aria-label={t("events.aria")}
|
||||||
|
>
|
||||||
|
<div className="">{t("events.label")}</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
className={`${timelineType == "activity" ? "" : "text-muted-foreground"}`}
|
||||||
|
value="activity"
|
||||||
|
aria-label="Activity Stream"
|
||||||
|
>
|
||||||
|
<div className="">Activity</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
) : (
|
||||||
|
<MobileTimelineDrawer
|
||||||
|
selected={timelineType ?? "timeline"}
|
||||||
|
onSelect={setTimelineType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MobileReviewSettingsDrawer
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
|
filter={filter}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
latestTime={timeRange.before}
|
latestTime={timeRange.before}
|
||||||
|
recordingsSummary={recordingsSummary}
|
||||||
mode={exportMode}
|
mode={exportMode}
|
||||||
range={exportRange}
|
range={exportRange}
|
||||||
showPreview={showExportPreview}
|
showExportPreview={showExportPreview}
|
||||||
setRange={(range) => {
|
allLabels={reviewFilterList.labels}
|
||||||
setExportRange(range);
|
allZones={reviewFilterList.zones}
|
||||||
|
onUpdateFilter={updateFilter}
|
||||||
if (range != undefined) {
|
setRange={setExportRange}
|
||||||
mainControllerRef.current?.pause();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
setMode={setExportMode}
|
setMode={setExportMode}
|
||||||
setShowPreview={setShowExportPreview}
|
setShowExportPreview={setShowExportPreview}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
{isDesktop && (
|
|
||||||
<ReviewFilterGroup
|
|
||||||
filters={["cameras", "date", "general"]}
|
|
||||||
reviewSummary={reviewSummary}
|
|
||||||
recordingsSummary={recordingsSummary}
|
|
||||||
filter={filter}
|
|
||||||
motionOnly={false}
|
|
||||||
filterList={reviewFilterList}
|
|
||||||
showReviewed
|
|
||||||
setShowReviewed={() => {}}
|
|
||||||
mainCamera={mainCamera}
|
|
||||||
onUpdateFilter={(newFilter: ReviewFilter) => {
|
|
||||||
const updatedCameras =
|
|
||||||
newFilter.cameras === undefined
|
|
||||||
? undefined // Respect undefined as "all cameras"
|
|
||||||
: newFilter.cameras
|
|
||||||
? Array.from(
|
|
||||||
new Set([mainCamera, ...(newFilter.cameras || [])]),
|
|
||||||
) // Include mainCamera if specific cameras are selected
|
|
||||||
: [mainCamera];
|
|
||||||
const adjustedFilter: ReviewFilter = {
|
|
||||||
...newFilter,
|
|
||||||
cameras: updatedCameras,
|
|
||||||
};
|
|
||||||
updateFilter(adjustedFilter);
|
|
||||||
}}
|
|
||||||
setMotionOnly={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isDesktop ? (
|
|
||||||
<ToggleGroup
|
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
value={timelineType}
|
|
||||||
onValueChange={(value: TimelineType) =>
|
|
||||||
value ? setTimelineType(value, true) : null
|
|
||||||
} // don't allow the severity to be unselected
|
|
||||||
>
|
|
||||||
<ToggleGroupItem
|
|
||||||
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
|
|
||||||
value="timeline"
|
|
||||||
aria-label={t("timeline.aria")}
|
|
||||||
>
|
|
||||||
<div className="">{t("timeline")}</div>
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem
|
|
||||||
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
|
|
||||||
value="events"
|
|
||||||
aria-label={t("events.aria")}
|
|
||||||
>
|
|
||||||
<div className="">{t("events.label")}</div>
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
) : (
|
|
||||||
<MobileTimelineDrawer
|
|
||||||
selected={timelineType ?? "timeline"}
|
|
||||||
onSelect={setTimelineType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MobileReviewSettingsDrawer
|
|
||||||
camera={mainCamera}
|
|
||||||
filter={filter}
|
|
||||||
currentTime={currentTime}
|
|
||||||
latestTime={timeRange.before}
|
|
||||||
recordingsSummary={recordingsSummary}
|
|
||||||
mode={exportMode}
|
|
||||||
range={exportRange}
|
|
||||||
showExportPreview={showExportPreview}
|
|
||||||
allLabels={reviewFilterList.labels}
|
|
||||||
allZones={reviewFilterList.zones}
|
|
||||||
onUpdateFilter={updateFilter}
|
|
||||||
setRange={setExportRange}
|
|
||||||
setMode={setExportMode}
|
|
||||||
setShowExportPreview={setShowExportPreview}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={mainLayoutRef}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full justify-center overflow-hidden",
|
|
||||||
isDesktop ? "" : "flex-col gap-2 landscape:flex-row",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={cameraLayoutRef}
|
ref={mainLayoutRef}
|
||||||
className={cn("flex flex-1 flex-wrap", isDesktop ? "w-[80%]" : "")}
|
className={cn(
|
||||||
|
"flex h-full justify-center overflow-hidden",
|
||||||
|
isDesktop ? "" : "flex-col gap-2 landscape:flex-row",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={cameraLayoutRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-full items-center",
|
"flex flex-1 flex-wrap",
|
||||||
mainCameraAspect == "tall"
|
isDesktop
|
||||||
? "flex-row justify-evenly"
|
? timelineType === "activity"
|
||||||
: "flex-col justify-center gap-2",
|
? "w-full"
|
||||||
|
: "w-[80%]"
|
||||||
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
key={mainCamera}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative",
|
"flex size-full items-center",
|
||||||
isDesktop
|
timelineType === "activity"
|
||||||
? cn(
|
? "flex-col"
|
||||||
"flex justify-center px-4",
|
: mainCameraAspect == "tall"
|
||||||
mainCameraAspect == "tall"
|
? "flex-row justify-evenly"
|
||||||
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]"
|
: "flex-col justify-center gap-2",
|
||||||
: mainCameraAspect == "wide"
|
|
||||||
? "w-full"
|
|
||||||
: "",
|
|
||||||
)
|
|
||||||
: cn(
|
|
||||||
"pt-2 portrait:w-full",
|
|
||||||
isMobileOnly &&
|
|
||||||
(mainCameraAspect == "wide"
|
|
||||||
? "aspect-wide landscape:w-full"
|
|
||||||
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]"),
|
|
||||||
isTablet &&
|
|
||||||
(mainCameraAspect == "wide"
|
|
||||||
? "aspect-wide landscape:w-full"
|
|
||||||
: mainCameraAspect == "normal"
|
|
||||||
? "landscape:w-full"
|
|
||||||
: "aspect-video landscape:h-[100%]"),
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
width: mainCameraStyle ? mainCameraStyle.width : undefined,
|
|
||||||
aspectRatio: isDesktop
|
|
||||||
? mainCameraAspect == "tall"
|
|
||||||
? getCameraAspect(mainCamera)
|
|
||||||
: undefined
|
|
||||||
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isDesktop && (
|
<div
|
||||||
|
key={mainCamera}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
isDesktop
|
||||||
|
? cn(
|
||||||
|
"flex justify-center px-4",
|
||||||
|
mainCameraAspect == "tall"
|
||||||
|
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]"
|
||||||
|
: mainCameraAspect == "wide"
|
||||||
|
? "w-full"
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
: cn(
|
||||||
|
"pt-2 portrait:w-full",
|
||||||
|
isMobileOnly &&
|
||||||
|
(mainCameraAspect == "wide"
|
||||||
|
? "aspect-wide landscape:w-full"
|
||||||
|
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]"),
|
||||||
|
isTablet &&
|
||||||
|
(mainCameraAspect == "wide"
|
||||||
|
? "aspect-wide landscape:w-full"
|
||||||
|
: mainCameraAspect == "normal"
|
||||||
|
? "landscape:w-full"
|
||||||
|
: "aspect-video landscape:h-[100%]"),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: mainCameraStyle ? mainCameraStyle.width : undefined,
|
||||||
|
aspectRatio: isDesktop
|
||||||
|
? mainCameraAspect == "tall"
|
||||||
|
? getCameraAspect(mainCamera)
|
||||||
|
: undefined
|
||||||
|
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesktop && (
|
||||||
<GenAISummaryDialog
|
<GenAISummaryDialog
|
||||||
review={activeReviewItem}
|
review={activeReviewItem}
|
||||||
onOpen={onAnalysisOpen}
|
onOpen={onAnalysisOpen}
|
||||||
@ -724,106 +791,112 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DynamicVideoPlayer
|
<DynamicVideoPlayer
|
||||||
className={grow}
|
className={grow}
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
startTimestamp={playbackStart}
|
startTimestamp={playbackStart}
|
||||||
hotKeys={exportMode != "select"}
|
hotKeys={exportMode != "select"}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
onTimestampUpdate={(timestamp) => {
|
onTimestampUpdate={(timestamp) => {
|
||||||
setPlayerTime(timestamp);
|
setPlayerTime(timestamp);
|
||||||
setCurrentTime(timestamp);
|
setCurrentTime(timestamp);
|
||||||
Object.values(previewRefs.current ?? {}).forEach((prev) =>
|
Object.values(previewRefs.current ?? {}).forEach((prev) =>
|
||||||
prev.scrubToTimestamp(Math.floor(timestamp)),
|
prev.scrubToTimestamp(Math.floor(timestamp)),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClipEnded={onClipEnded}
|
onClipEnded={onClipEnded}
|
||||||
onControllerReady={(controller) => {
|
onSeekToTime={manuallySetCurrentTime}
|
||||||
mainControllerRef.current = controller;
|
onControllerReady={(controller) => {
|
||||||
}}
|
mainControllerRef.current = controller;
|
||||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
}}
|
||||||
supportsFullscreen={supportsFullScreen}
|
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||||
setFullResolution={setFullResolution}
|
supportsFullscreen={supportsFullScreen}
|
||||||
toggleFullscreen={toggleFullscreen}
|
setFullResolution={setFullResolution}
|
||||||
containerRef={mainLayoutRef}
|
toggleFullscreen={toggleFullscreen}
|
||||||
/>
|
containerRef={mainLayoutRef}
|
||||||
</div>
|
/>
|
||||||
{isDesktop && effectiveCameras.length > 1 && (
|
|
||||||
<div
|
|
||||||
ref={previewRowRef}
|
|
||||||
className={cn(
|
|
||||||
"scrollbar-container flex gap-2 overflow-auto",
|
|
||||||
mainCameraAspect == "tall"
|
|
||||||
? "h-full w-72 flex-col"
|
|
||||||
: `h-28 w-full`,
|
|
||||||
previewRowOverflows ? "" : "items-center justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-2" />
|
|
||||||
{effectiveCameras.map((cam) => {
|
|
||||||
if (cam == mainCamera || cam == "birdseye") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={cam}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
mainCameraAspect == "tall" ? "w-full" : "h-full"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
aspectRatio: getCameraAspect(cam),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PreviewPlayer
|
|
||||||
previewRef={previewRef}
|
|
||||||
className="size-full"
|
|
||||||
camera={cam}
|
|
||||||
timeRange={currentTimeRange}
|
|
||||||
cameraPreviews={allPreviews ?? []}
|
|
||||||
startTime={startTime}
|
|
||||||
isScrubbing={scrubbing}
|
|
||||||
isVisible={visiblePreviews.includes(cam)}
|
|
||||||
onControllerReady={(controller) => {
|
|
||||||
previewRefs.current[cam] = controller;
|
|
||||||
controller.scrubToTimestamp(startTime);
|
|
||||||
}}
|
|
||||||
onClick={() => onSelectCamera(cam)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="smart-capitalize">
|
|
||||||
<CameraNameLabel camera={cam} />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="w-2" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isDesktop &&
|
||||||
|
effectiveCameras.length > 1 &&
|
||||||
|
timelineType !== "activity" && (
|
||||||
|
<div
|
||||||
|
ref={previewRowRef}
|
||||||
|
className={cn(
|
||||||
|
"scrollbar-container flex gap-2 overflow-auto",
|
||||||
|
mainCameraAspect == "tall"
|
||||||
|
? "h-full w-72 flex-col"
|
||||||
|
: `h-28 w-full`,
|
||||||
|
previewRowOverflows ? "" : "items-center justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-2" />
|
||||||
|
{effectiveCameras.map((cam) => {
|
||||||
|
if (cam == mainCamera || cam == "birdseye") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={cam}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
mainCameraAspect == "tall" ? "w-full" : "h-full"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
aspectRatio: getCameraAspect(cam),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PreviewPlayer
|
||||||
|
previewRef={previewRef}
|
||||||
|
className="size-full"
|
||||||
|
camera={cam}
|
||||||
|
timeRange={currentTimeRange}
|
||||||
|
cameraPreviews={allPreviews ?? []}
|
||||||
|
startTime={startTime}
|
||||||
|
isScrubbing={scrubbing}
|
||||||
|
isVisible={visiblePreviews.includes(cam)}
|
||||||
|
onControllerReady={(controller) => {
|
||||||
|
previewRefs.current[cam] = controller;
|
||||||
|
controller.scrubToTimestamp(startTime);
|
||||||
|
}}
|
||||||
|
onClick={() => onSelectCamera(cam)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="smart-capitalize">
|
||||||
|
<CameraNameLabel camera={cam} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="w-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Timeline
|
||||||
<Timeline
|
contentRef={contentRef}
|
||||||
contentRef={contentRef}
|
mainCamera={mainCamera}
|
||||||
mainCamera={mainCamera}
|
timelineType={
|
||||||
timelineType={
|
(exportRange == undefined ? timelineType : "timeline") ??
|
||||||
(exportRange == undefined ? timelineType : "timeline") ?? "timeline"
|
"timeline"
|
||||||
}
|
}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
mainCameraReviewItems={mainCameraReviewItems}
|
mainCameraReviewItems={mainCameraReviewItems}
|
||||||
activeReviewItem={activeReviewItem}
|
activeReviewItem={activeReviewItem}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
exportRange={exportMode == "timeline" ? exportRange : undefined}
|
exportRange={exportMode == "timeline" ? exportRange : undefined}
|
||||||
setCurrentTime={setCurrentTime}
|
setCurrentTime={setCurrentTime}
|
||||||
manuallySetCurrentTime={manuallySetCurrentTime}
|
manuallySetCurrentTime={manuallySetCurrentTime}
|
||||||
setScrubbing={setScrubbing}
|
setScrubbing={setScrubbing}
|
||||||
setExportRange={setExportRange}
|
setExportRange={setExportRange}
|
||||||
onAnalysisOpen={onAnalysisOpen}
|
timelineData={timelineData}
|
||||||
|
onAnalysisOpen={onAnalysisOpen}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ActivityStreamProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -841,6 +914,7 @@ type TimelineProps = {
|
|||||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setExportRange: (range: TimeRange) => void;
|
setExportRange: (range: TimeRange) => void;
|
||||||
|
timelineData?: ObjectLifecycleSequence[];
|
||||||
onAnalysisOpen: (open: boolean) => void;
|
onAnalysisOpen: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
function Timeline({
|
function Timeline({
|
||||||
@ -857,6 +931,7 @@ function Timeline({
|
|||||||
manuallySetCurrentTime,
|
manuallySetCurrentTime,
|
||||||
setScrubbing,
|
setScrubbing,
|
||||||
setExportRange,
|
setExportRange,
|
||||||
|
timelineData,
|
||||||
onAnalysisOpen,
|
onAnalysisOpen,
|
||||||
}: TimelineProps) {
|
}: TimelineProps) {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
@ -938,9 +1013,9 @@ function Timeline({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative",
|
"relative",
|
||||||
isDesktop
|
isDesktop
|
||||||
? `${timelineType == "timeline" ? "w-[100px]" : "w-60"} no-scrollbar overflow-y-auto`
|
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "activity" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto`
|
||||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : "landscape:w-[175px]"}`,
|
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "activity" ? "flex-1" : "landscape:w-[175px]"} `
|
||||||
)}
|
} relative`}
|
||||||
>
|
>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||||
@ -975,6 +1050,12 @@ function Timeline({
|
|||||||
) : (
|
) : (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
)
|
)
|
||||||
|
) : timelineType == "activity" ? (
|
||||||
|
<ActivityStream
|
||||||
|
timelineData={timelineData ?? []}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onSeek={(timestamp) => manuallySetCurrentTime(timestamp, true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user