mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-12 10:07:36 +03:00
match figma
This commit is contained in:
parent
c6ef729eb5
commit
6b65cf6f98
@ -63,7 +63,11 @@ export class DynamicVideoController {
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return !this.playerController.paused && !this.playerController.ended;
|
||||
return (
|
||||
!this.playerController.paused &&
|
||||
!this.playerController.ended &&
|
||||
this.playerController.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA
|
||||
);
|
||||
}
|
||||
|
||||
seekToTimestamp(time: number, play: boolean = false) {
|
||||
@ -109,8 +113,10 @@ export class DynamicVideoController {
|
||||
this.playerController.currentTime = seekSeconds;
|
||||
|
||||
if (play) {
|
||||
console.log("seeking and playing");
|
||||
this.waitAndPlay();
|
||||
} else {
|
||||
console.log("seeking and pausing");
|
||||
this.playerController.pause();
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import useUserInteraction from "@/hooks/use-user-interaction";
|
||||
import {
|
||||
formatUnixTimestampToDateTime,
|
||||
formatSecondsToDuration,
|
||||
getDurationFromTimestamps,
|
||||
} from "@/utils/dateUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
|
||||
@ -17,12 +16,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Event } from "@/types/event";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { LuChevronUp, LuChevronDown } from "react-icons/lu";
|
||||
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import EventMenu from "@/components/timeline/EventMenu";
|
||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||
@ -31,12 +25,14 @@ import { cn } from "@/lib/utils";
|
||||
type DetailStreamProps = {
|
||||
reviewItems?: ReviewSegment[];
|
||||
currentTime: number;
|
||||
isPlaying?: boolean;
|
||||
onSeek: (timestamp: number, play?: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DetailStream({
|
||||
reviewItems,
|
||||
currentTime,
|
||||
isPlaying = false,
|
||||
onSeek,
|
||||
}: DetailStreamProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -54,6 +50,11 @@ export default function DetailStream({
|
||||
const effectiveTime = currentTime + annotationOffset / 1000;
|
||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||
|
||||
const onSeekCheckPlaying = (timestamp: number) => {
|
||||
console.log("DetailStream onSeekCheckPlaying, isPlaying:", isPlaying);
|
||||
onSeek(timestamp, isPlaying);
|
||||
};
|
||||
|
||||
// Ensure we initialize the active review when reviewItems first arrive.
|
||||
// This helps when the component mounts while the video is already
|
||||
// playing — it guarantees the matching review is highlighted right
|
||||
@ -89,7 +90,7 @@ export default function DetailStream({
|
||||
|
||||
// Auto-scroll to current time
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current || userInteracting) return;
|
||||
if (!scrollRef.current || userInteracting || !isPlaying) return;
|
||||
// Prefer the review whose range contains the effectiveTime. If none
|
||||
// contains it, pick the nearest review (by mid-point distance). This is
|
||||
// robust to unordered reviewItems and avoids always picking the last
|
||||
@ -121,11 +122,21 @@ export default function DetailStream({
|
||||
`[data-review-id="${id}"]`,
|
||||
) as HTMLElement;
|
||||
if (element) {
|
||||
setProgrammaticScroll();
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
});
|
||||
// Only scroll if element is completely out of view
|
||||
const containerRect = scrollRef.current.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const isFullyInvisible =
|
||||
elementRect.bottom < containerRect.top ||
|
||||
elementRect.top > containerRect.bottom;
|
||||
console.log(scrollRef.current, element, isFullyInvisible);
|
||||
|
||||
if (isFullyInvisible) {
|
||||
setProgrammaticScroll();
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@ -134,6 +145,7 @@ export default function DetailStream({
|
||||
annotationOffset,
|
||||
userInteracting,
|
||||
setProgrammaticScroll,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
// Auto-select active review based on effectiveTime (if inside a review range)
|
||||
@ -165,9 +177,9 @@ export default function DetailStream({
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto bg-secondary"
|
||||
className="scrollbar-container h-[calc(100vh-70px)] overflow-y-auto"
|
||||
>
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="space-y-4 py-2">
|
||||
{reviewItems?.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("detail.noDataFound")}
|
||||
@ -181,7 +193,7 @@ export default function DetailStream({
|
||||
id={id}
|
||||
review={review}
|
||||
config={config}
|
||||
onSeek={onSeek}
|
||||
onSeek={onSeekCheckPlaying}
|
||||
effectiveTime={effectiveTime}
|
||||
isActive={activeReviewId == id}
|
||||
onActivate={() => setActiveReviewId(id)}
|
||||
@ -220,6 +232,7 @@ function ReviewGroup({
|
||||
effectiveTime,
|
||||
}: ReviewGroupProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const [open, setOpen] = useState(false);
|
||||
const start = review.start_time ?? 0;
|
||||
|
||||
const displayTime = formatUnixTimestampToDateTime(start, {
|
||||
@ -234,7 +247,7 @@ function ReviewGroup({
|
||||
|
||||
const shouldFetchEvents = review?.data?.detections?.length > 0;
|
||||
|
||||
const { data: fetchedEvents } = useSWR<Event[]>(
|
||||
const { data: fetchedEvents, isValidating } = useSWR<Event[]>(
|
||||
shouldFetchEvents
|
||||
? ["event_ids", { ids: review.data.detections.join(",") }]
|
||||
: null,
|
||||
@ -259,28 +272,27 @@ function ReviewGroup({
|
||||
}
|
||||
|
||||
const reviewInfo = useMemo(() => {
|
||||
if (review.data.metadata?.title) {
|
||||
return review.data.metadata.title;
|
||||
} else {
|
||||
const objectCount = fetchedEvents
|
||||
? fetchedEvents.length
|
||||
: (review.data.objects ?? []).length;
|
||||
const objectCount = fetchedEvents
|
||||
? fetchedEvents.length
|
||||
: (review.data.objects ?? []).length;
|
||||
|
||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
||||
}
|
||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
||||
}, [review, t, fetchedEvents]);
|
||||
|
||||
const reviewDuration =
|
||||
review.end_time != null
|
||||
? formatSecondsToDuration(
|
||||
Math.max(0, Math.floor((review.end_time ?? 0) - start)),
|
||||
)
|
||||
: null;
|
||||
const reviewDuration = useMemo(
|
||||
() =>
|
||||
getDurationFromTimestamps(
|
||||
review.start_time,
|
||||
review.end_time ?? null,
|
||||
true,
|
||||
),
|
||||
[review.start_time, review.end_time],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-review-id={id}
|
||||
className={`cursor-pointer rounded-lg border bg-background p-3 outline outline-[3px] -outline-offset-[2.8px] ${
|
||||
className={`cursor-pointer rounded-lg bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] ${
|
||||
isActive
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500"
|
||||
@ -293,40 +305,73 @@ function ReviewGroup({
|
||||
onSeek(start);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="ml-1 flex flex-col items-start gap-1.5">
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="text-sm font-medium">{displayTime}</div>
|
||||
{reviewDuration && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{reviewDuration}
|
||||
<div className="flex items-center gap-2">
|
||||
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
||||
<div
|
||||
key={`${lbl}-${idx}`}
|
||||
className="rounded-full bg-muted-foreground p-1"
|
||||
>
|
||||
{getIconForLabel(lbl, "size-3 text-primary dark:text-white")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{review.data.metadata?.title && (
|
||||
<div className="mb-1 text-sm text-primary-variant">
|
||||
{review.data.metadata.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">{reviewInfo}</div>
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<div className="text-xs text-primary-variant">{reviewInfo}</div>
|
||||
|
||||
{reviewDuration && (
|
||||
<>
|
||||
<span className="text-[5px] text-primary-variant">•</span>
|
||||
<div className="text-xs text-primary-variant">
|
||||
{reviewDuration}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
||||
<span key={`${lbl}-${idx}`}>
|
||||
{getIconForLabel(lbl, "size-4 text-primary dark:text-white")}
|
||||
</span>
|
||||
))}
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10"
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="size-4 text-primary-variant" />
|
||||
) : (
|
||||
<LuChevronRight className="size-4 text-primary-variant" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{shouldFetchEvents && !fetchedEvents ? (
|
||||
{open && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{shouldFetchEvents && isValidating && !fetchedEvents ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
(fetchedEvents || []).map((event) => {
|
||||
return (
|
||||
<EventCollapsible
|
||||
key={event.id}
|
||||
event={event}
|
||||
effectiveTime={effectiveTime}
|
||||
onSeek={onSeek}
|
||||
onOpenUpload={onOpenUpload}
|
||||
/>
|
||||
<>
|
||||
<div className="border-b border-secondary-highlight pb-0.5 last:border-0 last:pb-0">
|
||||
<EventList
|
||||
key={event.id}
|
||||
event={event}
|
||||
effectiveTime={effectiveTime}
|
||||
onSeek={onSeek}
|
||||
onOpenUpload={onOpenUpload}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@ -337,11 +382,13 @@ function ReviewGroup({
|
||||
key={audioLabel}
|
||||
className="rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{getIconForLabel(
|
||||
audioLabel,
|
||||
"size-4 text-primary dark:text-white",
|
||||
)}
|
||||
<div className="ml-1.5 flex items-center gap-2 text-sm font-medium">
|
||||
<div className="rounded-full bg-muted-foreground p-1">
|
||||
{getIconForLabel(
|
||||
audioLabel,
|
||||
"size-3 text-primary dark:text-white",
|
||||
)}
|
||||
</div>
|
||||
<span>{getTranslatedLabel(audioLabel)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -354,55 +401,30 @@ function ReviewGroup({
|
||||
);
|
||||
}
|
||||
|
||||
type EventCollapsibleProps = {
|
||||
type EventListProps = {
|
||||
event: Event;
|
||||
effectiveTime?: number;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
onOpenUpload?: (e: Event) => void;
|
||||
};
|
||||
function EventCollapsible({
|
||||
function EventList({
|
||||
event,
|
||||
effectiveTime,
|
||||
onSeek,
|
||||
onOpenUpload,
|
||||
}: EventCollapsibleProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation("views/events");
|
||||
}: EventListProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const { selectedObjectId, setSelectedObjectId } = useDetailStream();
|
||||
|
||||
const formattedStart = config
|
||||
? formatUnixTimestampToDateTime(event.start_time ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
|
||||
const formattedEnd = config
|
||||
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
||||
timezone: config.ui.timezone,
|
||||
date_format:
|
||||
config.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
})
|
||||
: "";
|
||||
const handleObjectSelect = (event: Event | undefined) => {
|
||||
if (event) {
|
||||
onSeek(event.start_time ?? 0);
|
||||
setSelectedObjectId(event.id);
|
||||
} else {
|
||||
setSelectedObjectId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
||||
useEffect(() => {
|
||||
@ -420,39 +442,44 @@ function EventCollapsible({
|
||||
]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={(o) => setOpen(o)}>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
|
||||
event.id == selectedObjectId
|
||||
? "shadow-selected outline-selected"
|
||||
? "bg-secondary-highlight shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
event.id != selectedObjectId &&
|
||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||
(effectiveTime ?? 0) <=
|
||||
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
||||
"bg-secondary-highlight outline-[1.5px] -outline-offset-[1.1px] outline-primary/40",
|
||||
"bg-secondary-highlight",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="ml-1.5 flex w-full items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSeek(event.start_time ?? 0);
|
||||
if (event.id) setSelectedObjectId(event.id);
|
||||
handleObjectSelect(selectedObjectId ? undefined : event);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.label,
|
||||
"size-4 text-primary dark:text-white",
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-1",
|
||||
event.id == selectedObjectId
|
||||
? "bg-selected"
|
||||
: "bg-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.label,
|
||||
"size-3 text-primary dark:text-white",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span>{getTranslatedLabel(event.label)}</span>
|
||||
<span className="text-xs text-secondary-foreground">
|
||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-row justify-end">
|
||||
@ -460,36 +487,21 @@ function EventCollapsible({
|
||||
event={event}
|
||||
config={config}
|
||||
onOpenUpload={(e) => onOpenUpload?.(e)}
|
||||
selectedObjectId={selectedObjectId}
|
||||
setSelectedObjectId={handleObjectSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded bg-muted px-2 py-1 text-xs"
|
||||
aria-label={t("detail.aria")}
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronUp className="size-3" />
|
||||
) : (
|
||||
<LuChevronDown className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2">
|
||||
<ObjectTimeline
|
||||
eventId={event.id}
|
||||
onSeek={onSeek}
|
||||
effectiveTime={effectiveTime}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
<div className="mt-2">
|
||||
<ObjectTimeline
|
||||
eventId={event.id}
|
||||
onSeek={onSeek}
|
||||
effectiveTime={effectiveTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -497,9 +509,15 @@ type LifecycleItemProps = {
|
||||
event: ObjectLifecycleSequence;
|
||||
isActive?: boolean;
|
||||
onSeek?: (timestamp: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
};
|
||||
|
||||
function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
||||
function LifecycleItem({
|
||||
event,
|
||||
isActive,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
}: LifecycleItemProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -532,8 +550,14 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
|
||||
: "duration-500",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<LifecycleIcon lifecycleItem={event} className="size-3" />
|
||||
<div className="relative flex size-4 items-center justify-center">
|
||||
<LuCircle
|
||||
className={cn(
|
||||
"relative z-10 size-2.5 fill-secondary-foreground stroke-none",
|
||||
(isActive || (effectiveTime ?? 0) >= (event.timestamp ?? 0)) &&
|
||||
"fill-selected duration-300",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-row justify-between">
|
||||
<div>{getLifecycleItemDescription(event)}</div>
|
||||
@ -561,8 +585,8 @@ function ObjectTimeline({
|
||||
},
|
||||
]);
|
||||
|
||||
if ((!timeline || timeline.length === 0) && isValidating) {
|
||||
return <ActivityIndicator className="h-2 w-2" size={2} />;
|
||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||
return <ActivityIndicator className="ml-2 size-3" />;
|
||||
}
|
||||
|
||||
if (!timeline || timeline.length === 0) {
|
||||
@ -573,20 +597,46 @@ function ObjectTimeline({
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate how far down the blue line should extend based on effectiveTime
|
||||
const calculateLineHeight = () => {
|
||||
if (!timeline || timeline.length === 0) return 0;
|
||||
|
||||
const firstTimestamp = timeline[0].timestamp ?? 0;
|
||||
const lastTimestamp = timeline[timeline.length - 1].timestamp ?? 0;
|
||||
|
||||
if ((effectiveTime ?? 0) <= firstTimestamp) return 0;
|
||||
if ((effectiveTime ?? 0) >= lastTimestamp) return 100;
|
||||
|
||||
const totalDuration = lastTimestamp - firstTimestamp;
|
||||
const elapsed = (effectiveTime ?? 0) - firstTimestamp;
|
||||
return Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
|
||||
};
|
||||
|
||||
const blueLineHeight = calculateLineHeight();
|
||||
|
||||
return (
|
||||
<div className="mx-2 mt-4 space-y-2">
|
||||
{timeline.map((event, idx) => {
|
||||
const isActive =
|
||||
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
||||
return (
|
||||
<LifecycleItem
|
||||
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
|
||||
event={event}
|
||||
onSeek={onSeek}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="-pb-2 relative mx-2">
|
||||
<div className="absolute -top-2 bottom-2 left-2 z-0 w-[1px] -translate-x-1/2 bg-secondary-foreground" />
|
||||
<div
|
||||
className="absolute left-2 top-2 z-[5] max-h-[calc(100%-1rem)] w-[1px] -translate-x-1/2 bg-selected transition-all duration-300"
|
||||
style={{ height: `${blueLineHeight}%` }}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{timeline.map((event, idx) => {
|
||||
const isActive =
|
||||
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5;
|
||||
|
||||
return (
|
||||
<LifecycleItem
|
||||
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`}
|
||||
event={event}
|
||||
onSeek={onSeek}
|
||||
isActive={isActive}
|
||||
effectiveTime={effectiveTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,19 +4,22 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { useApiHost } from "@/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Event } from "@/types/event";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
type EventMenuProps = {
|
||||
event: Event;
|
||||
config?: FrigateConfig;
|
||||
onOpenUpload?: (e: Event) => void;
|
||||
onOpenSimilarity?: (e: Event) => void;
|
||||
selectedObjectId?: string;
|
||||
setSelectedObjectId?: (event: Event | undefined) => void;
|
||||
};
|
||||
|
||||
export default function EventMenu({
|
||||
@ -24,71 +27,87 @@ export default function EventMenu({
|
||||
config,
|
||||
onOpenUpload,
|
||||
onOpenSimilarity,
|
||||
selectedObjectId,
|
||||
setSelectedObjectId,
|
||||
}: EventMenuProps) {
|
||||
const apiHost = useApiHost();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("views/explore");
|
||||
|
||||
const handleObjectSelect = () => {
|
||||
if (event.id === selectedObjectId) {
|
||||
setSelectedObjectId?.(undefined);
|
||||
} else {
|
||||
setSelectedObjectId?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<button
|
||||
className="mr-2 rounded p-1"
|
||||
aria-label={t("itemMenu.openMenu", { ns: "common" })}
|
||||
>
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
{t("details.item.button.viewInExplore")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
download
|
||||
href={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||
}
|
||||
>
|
||||
{t("itemMenu.downloadSnapshot.label")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{event.has_snapshot &&
|
||||
event.plus_id == undefined &&
|
||||
event.data.type == "object" &&
|
||||
config?.plus?.enabled && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onOpenUpload?.(event);
|
||||
}}
|
||||
>
|
||||
{t("itemMenu.submitToPlus.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{event.has_snapshot && config?.semantic_search?.enabled && (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={handleObjectSelect}>
|
||||
{event.id === selectedObjectId
|
||||
? t("itemMenu.hideObjectDetails.label")
|
||||
: t("itemMenu.showObjectDetails.label")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-0.5" />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (onOpenSimilarity) onOpenSimilarity(event);
|
||||
else
|
||||
navigate(
|
||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||
);
|
||||
navigate(`/explore?event_id=${event.id}`);
|
||||
}}
|
||||
>
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
{t("details.item.button.viewInExplore")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
download
|
||||
href={
|
||||
event.has_snapshot
|
||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${event.id}/thumbnail.webp`
|
||||
}
|
||||
>
|
||||
{t("itemMenu.downloadSnapshot.label")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{event.has_snapshot &&
|
||||
event.plus_id == undefined &&
|
||||
event.data.type == "object" &&
|
||||
config?.plus?.enabled && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onOpenUpload?.(event);
|
||||
}}
|
||||
>
|
||||
{t("itemMenu.submitToPlus.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{event.has_snapshot && config?.semantic_search?.enabled && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
if (onOpenSimilarity) onOpenSimilarity(event);
|
||||
else
|
||||
navigate(
|
||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -284,14 +284,17 @@ export function RecordingView({
|
||||
|
||||
const manuallySetCurrentTime = useCallback(
|
||||
(time: number, play: boolean = false) => {
|
||||
console.log("manuallySetCurrentTime:", time, "play:", play);
|
||||
if (!currentTimeRange) {
|
||||
return;
|
||||
}
|
||||
setCurrentTime(time);
|
||||
|
||||
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
|
||||
console.log("in range, seeking player");
|
||||
mainControllerRef.current?.seekToTimestamp(time, play);
|
||||
} else {
|
||||
console.log("out of range, updating segment");
|
||||
updateSelectedSegment(time, true);
|
||||
}
|
||||
},
|
||||
@ -310,6 +313,7 @@ export function RecordingView({
|
||||
updateSelectedSegment(currentTime, true);
|
||||
}
|
||||
} else if (playerTime != currentTime && timelineType != "detail") {
|
||||
console.log("Resuming playback after seek");
|
||||
mainControllerRef.current?.play();
|
||||
}
|
||||
}
|
||||
@ -516,12 +520,15 @@ export function RecordingView({
|
||||
if (open) {
|
||||
mainControllerRef.current?.pause();
|
||||
} else {
|
||||
console.log("Resuming playback after closing analysis dialog");
|
||||
mainControllerRef.current?.play();
|
||||
}
|
||||
},
|
||||
[mainControllerRef],
|
||||
);
|
||||
|
||||
console.log("in recordingview:", mainControllerRef?.current?.isPlaying());
|
||||
|
||||
return (
|
||||
<DetailStreamProvider
|
||||
isDetailMode={timelineType === "detail"}
|
||||
@ -695,8 +702,8 @@ export function RecordingView({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full items-center",
|
||||
timelineType === "detail"
|
||||
"aaa flex size-full items-center",
|
||||
timelineType === "detail" && isDesktop
|
||||
? "flex-col"
|
||||
: mainCameraAspect == "tall"
|
||||
? "flex-row justify-evenly"
|
||||
@ -782,7 +789,7 @@ export function RecordingView({
|
||||
? "h-full w-72 flex-col"
|
||||
: `h-28 w-full`,
|
||||
previewRowOverflows ? "" : "items-center justify-center",
|
||||
timelineType == "detail" && "mt-4",
|
||||
timelineType == "detail" && isDesktop && "mt-4",
|
||||
)}
|
||||
>
|
||||
<div className="w-2" />
|
||||
@ -847,6 +854,7 @@ export function RecordingView({
|
||||
setScrubbing={setScrubbing}
|
||||
setExportRange={setExportRange}
|
||||
onAnalysisOpen={onAnalysisOpen}
|
||||
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -864,6 +872,7 @@ type TimelineProps = {
|
||||
activeReviewItem?: ReviewSegment;
|
||||
currentTime: number;
|
||||
exportRange?: TimeRange;
|
||||
isPlaying?: boolean;
|
||||
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||
manuallySetCurrentTime: (time: number, force: boolean) => void;
|
||||
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@ -880,6 +889,7 @@ function Timeline({
|
||||
activeReviewItem,
|
||||
currentTime,
|
||||
exportRange,
|
||||
isPlaying,
|
||||
setCurrentTime,
|
||||
manuallySetCurrentTime,
|
||||
setScrubbing,
|
||||
@ -966,15 +976,19 @@ function Timeline({
|
||||
"relative",
|
||||
isDesktop
|
||||
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "detail" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto`
|
||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "detail" ? "flex-1" : "landscape:w-[175px]"} `,
|
||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "detail" && isDesktop ? "flex-1" : "landscape:w-[300px]"} `,
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||
{timelineType != "detail" && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||
</>
|
||||
)}
|
||||
{timelineType == "timeline" ? (
|
||||
!isLoading ? (
|
||||
<MotionReviewTimeline
|
||||
@ -1009,6 +1023,7 @@ function Timeline({
|
||||
manuallySetCurrentTime(timestamp, play ?? true)
|
||||
}
|
||||
reviewItems={mainCameraReviewItems}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
) : (
|
||||
<div className="scrollbar-container h-full overflow-auto bg-secondary">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user