match figma

This commit is contained in:
Josh Hawkins 2025-10-23 21:50:55 -05:00
parent c6ef729eb5
commit 6b65cf6f98
4 changed files with 307 additions and 217 deletions

View File

@ -63,7 +63,11 @@ export class DynamicVideoController {
} }
isPlaying(): boolean { 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) { seekToTimestamp(time: number, play: boolean = false) {
@ -109,8 +113,10 @@ export class DynamicVideoController {
this.playerController.currentTime = seekSeconds; this.playerController.currentTime = seekSeconds;
if (play) { if (play) {
console.log("seeking and playing");
this.waitAndPlay(); this.waitAndPlay();
} else { } else {
console.log("seeking and pausing");
this.playerController.pause(); this.playerController.pause();
} }
} else { } else {

View File

@ -1,13 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ObjectLifecycleSequence } from "@/types/timeline"; import { ObjectLifecycleSequence } from "@/types/timeline";
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useDetailStream } from "@/context/detail-stream-context"; import { useDetailStream } from "@/context/detail-stream-context";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import useUserInteraction from "@/hooks/use-user-interaction"; import useUserInteraction from "@/hooks/use-user-interaction";
import { import {
formatUnixTimestampToDateTime, formatUnixTimestampToDateTime,
formatSecondsToDuration, getDurationFromTimestamps,
} from "@/utils/dateUtil"; } from "@/utils/dateUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider"; import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
@ -17,12 +16,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@/components/ui/collapsible";
import { LuChevronUp, LuChevronDown } from "react-icons/lu";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import EventMenu from "@/components/timeline/EventMenu"; import EventMenu from "@/components/timeline/EventMenu";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog"; import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
@ -31,12 +25,14 @@ import { cn } from "@/lib/utils";
type DetailStreamProps = { type DetailStreamProps = {
reviewItems?: ReviewSegment[]; reviewItems?: ReviewSegment[];
currentTime: number; currentTime: number;
isPlaying?: boolean;
onSeek: (timestamp: number, play?: boolean) => void; onSeek: (timestamp: number, play?: boolean) => void;
}; };
export default function DetailStream({ export default function DetailStream({
reviewItems, reviewItems,
currentTime, currentTime,
isPlaying = false,
onSeek, onSeek,
}: DetailStreamProps) { }: DetailStreamProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -54,6 +50,11 @@ export default function DetailStream({
const effectiveTime = currentTime + annotationOffset / 1000; const effectiveTime = currentTime + annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined); 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. // Ensure we initialize the active review when reviewItems first arrive.
// This helps when the component mounts while the video is already // This helps when the component mounts while the video is already
// playing — it guarantees the matching review is highlighted right // playing — it guarantees the matching review is highlighted right
@ -89,7 +90,7 @@ export default function DetailStream({
// Auto-scroll to current time // Auto-scroll to current time
useEffect(() => { useEffect(() => {
if (!scrollRef.current || userInteracting) return; if (!scrollRef.current || userInteracting || !isPlaying) return;
// Prefer the review whose range contains the effectiveTime. If none // Prefer the review whose range contains the effectiveTime. If none
// contains it, pick the nearest review (by mid-point distance). This is // contains it, pick the nearest review (by mid-point distance). This is
// robust to unordered reviewItems and avoids always picking the last // robust to unordered reviewItems and avoids always picking the last
@ -121,11 +122,21 @@ export default function DetailStream({
`[data-review-id="${id}"]`, `[data-review-id="${id}"]`,
) as HTMLElement; ) as HTMLElement;
if (element) { if (element) {
setProgrammaticScroll(); // Only scroll if element is completely out of view
scrollIntoView(element, { const containerRect = scrollRef.current.getBoundingClientRect();
scrollMode: "if-needed", const elementRect = element.getBoundingClientRect();
behavior: "smooth", 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, annotationOffset,
userInteracting, userInteracting,
setProgrammaticScroll, setProgrammaticScroll,
isPlaying,
]); ]);
// Auto-select active review based on effectiveTime (if inside a review range) // Auto-select active review based on effectiveTime (if inside a review range)
@ -165,9 +177,9 @@ export default function DetailStream({
<div <div
ref={scrollRef} 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 ? ( {reviewItems?.length === 0 ? (
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
{t("detail.noDataFound")} {t("detail.noDataFound")}
@ -181,7 +193,7 @@ export default function DetailStream({
id={id} id={id}
review={review} review={review}
config={config} config={config}
onSeek={onSeek} onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
isActive={activeReviewId == id} isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)} onActivate={() => setActiveReviewId(id)}
@ -220,6 +232,7 @@ function ReviewGroup({
effectiveTime, effectiveTime,
}: ReviewGroupProps) { }: ReviewGroupProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false);
const start = review.start_time ?? 0; const start = review.start_time ?? 0;
const displayTime = formatUnixTimestampToDateTime(start, { const displayTime = formatUnixTimestampToDateTime(start, {
@ -234,7 +247,7 @@ function ReviewGroup({
const shouldFetchEvents = review?.data?.detections?.length > 0; const shouldFetchEvents = review?.data?.detections?.length > 0;
const { data: fetchedEvents } = useSWR<Event[]>( const { data: fetchedEvents, isValidating } = useSWR<Event[]>(
shouldFetchEvents shouldFetchEvents
? ["event_ids", { ids: review.data.detections.join(",") }] ? ["event_ids", { ids: review.data.detections.join(",") }]
: null, : null,
@ -259,28 +272,27 @@ function ReviewGroup({
} }
const reviewInfo = useMemo(() => { const reviewInfo = useMemo(() => {
if (review.data.metadata?.title) { const objectCount = fetchedEvents
return review.data.metadata.title; ? fetchedEvents.length
} else { : (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]); }, [review, t, fetchedEvents]);
const reviewDuration = const reviewDuration = useMemo(
review.end_time != null () =>
? formatSecondsToDuration( getDurationFromTimestamps(
Math.max(0, Math.floor((review.end_time ?? 0) - start)), review.start_time,
) review.end_time ?? null,
: null; true,
),
[review.start_time, review.end_time],
);
return ( return (
<div <div
data-review-id={id} 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 isActive
? "shadow-selected outline-selected" ? "shadow-selected outline-selected"
: "outline-transparent duration-500" : "outline-transparent duration-500"
@ -293,40 +305,73 @@ function ReviewGroup({
onSeek(start); onSeek(start);
}} }}
> >
<div className="flex items-center gap-2"> <div className="ml-1 flex flex-col items-start gap-1.5">
<div className="flex flex-col"> <div className="flex flex-row gap-3">
<div className="text-sm font-medium">{displayTime}</div> <div className="text-sm font-medium">{displayTime}</div>
{reviewDuration && ( <div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground"> {iconLabels.slice(0, 5).map((lbl, idx) => (
{reviewDuration} <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>
)} )}
<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> </div>
<div className="flex items-center gap-2"> <div
{iconLabels.slice(0, 5).map((lbl, idx) => ( onClick={() => {
<span key={`${lbl}-${idx}`}> setOpen((v) => !v);
{getIconForLabel(lbl, "size-4 text-primary dark:text-white")} }}
</span> 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>
</div> </div>
{isActive && ( {open && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-0.5">
{shouldFetchEvents && !fetchedEvents ? ( {shouldFetchEvents && isValidating && !fetchedEvents ? (
<ActivityIndicator /> <ActivityIndicator />
) : ( ) : (
(fetchedEvents || []).map((event) => { (fetchedEvents || []).map((event) => {
return ( return (
<EventCollapsible <>
key={event.id} <div className="border-b border-secondary-highlight pb-0.5 last:border-0 last:pb-0">
event={event} <EventList
effectiveTime={effectiveTime} key={event.id}
onSeek={onSeek} event={event}
onOpenUpload={onOpenUpload} effectiveTime={effectiveTime}
/> onSeek={onSeek}
onOpenUpload={onOpenUpload}
/>
</div>
</>
); );
}) })
)} )}
@ -337,11 +382,13 @@ function ReviewGroup({
key={audioLabel} key={audioLabel}
className="rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500" 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"> <div className="ml-1.5 flex items-center gap-2 text-sm font-medium">
{getIconForLabel( <div className="rounded-full bg-muted-foreground p-1">
audioLabel, {getIconForLabel(
"size-4 text-primary dark:text-white", audioLabel,
)} "size-3 text-primary dark:text-white",
)}
</div>
<span>{getTranslatedLabel(audioLabel)}</span> <span>{getTranslatedLabel(audioLabel)}</span>
</div> </div>
</div> </div>
@ -354,55 +401,30 @@ function ReviewGroup({
); );
} }
type EventCollapsibleProps = { type EventListProps = {
event: Event; event: Event;
effectiveTime?: number; effectiveTime?: number;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
}; };
function EventCollapsible({ function EventList({
event, event,
effectiveTime, effectiveTime,
onSeek, onSeek,
onOpenUpload, onOpenUpload,
}: EventCollapsibleProps) { }: EventListProps) {
const [open, setOpen] = useState(false);
const { t } = useTranslation("views/events");
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { selectedObjectId, setSelectedObjectId } = useDetailStream(); const { selectedObjectId, setSelectedObjectId } = useDetailStream();
const formattedStart = config const handleObjectSelect = (event: Event | undefined) => {
? formatUnixTimestampToDateTime(event.start_time ?? 0, { if (event) {
timezone: config.ui.timezone, onSeek(event.start_time ?? 0);
date_format: setSelectedObjectId(event.id);
config.ui.time_format == "24hour" } else {
? t("time.formattedTimestampHourMinuteSecond.24hour", { setSelectedObjectId(undefined);
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",
})
: "";
// Clear selectedObjectId when effectiveTime has passed this event's end_time // Clear selectedObjectId when effectiveTime has passed this event's end_time
useEffect(() => { useEffect(() => {
@ -420,39 +442,44 @@ function EventCollapsible({
]); ]);
return ( return (
<Collapsible open={open} onOpenChange={(o) => setOpen(o)}> <>
<div <div
className={cn( className={cn(
"rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]", "rounded-md bg-secondary p-2 outline outline-[3px] -outline-offset-[2.8px]",
event.id == selectedObjectId event.id == selectedObjectId
? "shadow-selected outline-selected" ? "bg-secondary-highlight shadow-selected outline-selected"
: "outline-transparent duration-500", : "outline-transparent duration-500",
event.id != selectedObjectId && event.id != selectedObjectId &&
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 && (effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
(effectiveTime ?? 0) <= (effectiveTime ?? 0) <=
(event.end_time ?? event.start_time ?? 0) + 0.5 && (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 <div
className="flex items-center gap-2 text-sm font-medium" className="flex items-center gap-2 text-sm font-medium"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time ?? 0); handleObjectSelect(selectedObjectId ? undefined : event);
if (event.id) setSelectedObjectId(event.id);
}} }}
role="button" role="button"
> >
{getIconForLabel( <div
event.label, className={cn(
"size-4 text-primary dark:text-white", "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"> <div className="flex items-end gap-2">
<span>{getTranslatedLabel(event.label)}</span> <span>{getTranslatedLabel(event.label)}</span>
<span className="text-xs text-secondary-foreground">
{formattedStart ?? ""} - {formattedEnd ?? ""}
</span>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-row justify-end"> <div className="flex flex-1 flex-row justify-end">
@ -460,36 +487,21 @@ function EventCollapsible({
event={event} event={event}
config={config} config={config}
onOpenUpload={(e) => onOpenUpload?.(e)} onOpenUpload={(e) => onOpenUpload?.(e)}
selectedObjectId={selectedObjectId}
setSelectedObjectId={handleObjectSelect}
/> />
</div> </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> </div>
<CollapsibleContent>
<div className="mt-2"> <div className="mt-2">
<ObjectTimeline <ObjectTimeline
eventId={event.id} eventId={event.id}
onSeek={onSeek} onSeek={onSeek}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
/> />
</div> </div>
</CollapsibleContent>
</div> </div>
</Collapsible> </>
); );
} }
@ -497,9 +509,15 @@ type LifecycleItemProps = {
event: ObjectLifecycleSequence; event: ObjectLifecycleSequence;
isActive?: boolean; isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void; 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 { t } = useTranslation("views/events");
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -532,8 +550,14 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) {
: "duration-500", : "duration-500",
)} )}
> >
<div className="flex size-4 items-center justify-center"> <div className="relative flex size-4 items-center justify-center">
<LifecycleIcon lifecycleItem={event} className="size-3" /> <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>
<div className="flex w-full flex-row justify-between"> <div className="flex w-full flex-row justify-between">
<div>{getLifecycleItemDescription(event)}</div> <div>{getLifecycleItemDescription(event)}</div>
@ -561,8 +585,8 @@ function ObjectTimeline({
}, },
]); ]);
if ((!timeline || timeline.length === 0) && isValidating) { if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="h-2 w-2" size={2} />; return <ActivityIndicator className="ml-2 size-3" />;
} }
if (!timeline || timeline.length === 0) { 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 ( return (
<div className="mx-2 mt-4 space-y-2"> <div className="-pb-2 relative mx-2">
{timeline.map((event, idx) => { <div className="absolute -top-2 bottom-2 left-2 z-0 w-[1px] -translate-x-1/2 bg-secondary-foreground" />
const isActive = <div
Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; 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"
return ( style={{ height: `${blueLineHeight}%` }}
<LifecycleItem />
key={`${event.timestamp}-${event.source_id ?? ""}-${idx}`} <div className="space-y-2">
event={event} {timeline.map((event, idx) => {
onSeek={onSeek} const isActive =
isActive={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> </div>
); );
} }

View File

@ -4,19 +4,22 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Event } from "@/types/event"; import { Event } from "@/types/event";
import type { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
type EventMenuProps = { type EventMenuProps = {
event: Event; event: Event;
config?: FrigateConfig; config?: FrigateConfig;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
onOpenSimilarity?: (e: Event) => void; onOpenSimilarity?: (e: Event) => void;
selectedObjectId?: string;
setSelectedObjectId?: (event: Event | undefined) => void;
}; };
export default function EventMenu({ export default function EventMenu({
@ -24,71 +27,87 @@ export default function EventMenu({
config, config,
onOpenUpload, onOpenUpload,
onOpenSimilarity, onOpenSimilarity,
selectedObjectId,
setSelectedObjectId,
}: EventMenuProps) { }: EventMenuProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("views/explore"); const { t } = useTranslation("views/explore");
const handleObjectSelect = () => {
if (event.id === selectedObjectId) {
setSelectedObjectId?.(undefined);
} else {
setSelectedObjectId?.(event);
}
};
return ( return (
<DropdownMenu> <>
<DropdownMenuTrigger> <span tabIndex={0} className="sr-only" />
<button <DropdownMenu>
className="mr-2 rounded p-1" <DropdownMenuTrigger>
aria-label={t("itemMenu.openMenu", { ns: "common" })} <div className="rounded p-1 pr-2" role="button">
> <HiDotsHorizontal className="size-4 text-muted-foreground" />
<HiDotsHorizontal className="size-4 text-muted-foreground" /> </div>
</button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuPortal>
<DropdownMenuPortal> <DropdownMenuContent>
<DropdownMenuContent> <DropdownMenuItem onSelect={handleObjectSelect}>
<DropdownMenuItem {event.id === selectedObjectId
onSelect={() => { ? t("itemMenu.hideObjectDetails.label")
navigate(`/explore?event_id=${event.id}`); : t("itemMenu.showObjectDetails.label")}
}} </DropdownMenuItem>
> <DropdownMenuSeparator className="my-0.5" />
{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 && (
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
if (onOpenSimilarity) onOpenSimilarity(event); navigate(`/explore?event_id=${event.id}`);
else
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}} }}
> >
{t("itemMenu.findSimilar.label")} {t("details.item.button.viewInExplore")}
</DropdownMenuItem> </DropdownMenuItem>
)} <DropdownMenuItem asChild>
</DropdownMenuContent> <a
</DropdownMenuPortal> download
</DropdownMenu> 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>
</>
); );
} }

View File

@ -284,14 +284,17 @@ export function RecordingView({
const manuallySetCurrentTime = useCallback( const manuallySetCurrentTime = useCallback(
(time: number, play: boolean = false) => { (time: number, play: boolean = false) => {
console.log("manuallySetCurrentTime:", time, "play:", play);
if (!currentTimeRange) { if (!currentTimeRange) {
return; return;
} }
setCurrentTime(time); setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) { if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
console.log("in range, seeking player");
mainControllerRef.current?.seekToTimestamp(time, play); mainControllerRef.current?.seekToTimestamp(time, play);
} else { } else {
console.log("out of range, updating segment");
updateSelectedSegment(time, true); updateSelectedSegment(time, true);
} }
}, },
@ -310,6 +313,7 @@ export function RecordingView({
updateSelectedSegment(currentTime, true); updateSelectedSegment(currentTime, true);
} }
} else if (playerTime != currentTime && timelineType != "detail") { } else if (playerTime != currentTime && timelineType != "detail") {
console.log("Resuming playback after seek");
mainControllerRef.current?.play(); mainControllerRef.current?.play();
} }
} }
@ -516,12 +520,15 @@ export function RecordingView({
if (open) { if (open) {
mainControllerRef.current?.pause(); mainControllerRef.current?.pause();
} else { } else {
console.log("Resuming playback after closing analysis dialog");
mainControllerRef.current?.play(); mainControllerRef.current?.play();
} }
}, },
[mainControllerRef], [mainControllerRef],
); );
console.log("in recordingview:", mainControllerRef?.current?.isPlaying());
return ( return (
<DetailStreamProvider <DetailStreamProvider
isDetailMode={timelineType === "detail"} isDetailMode={timelineType === "detail"}
@ -695,8 +702,8 @@ export function RecordingView({
> >
<div <div
className={cn( className={cn(
"flex size-full items-center", "aaa flex size-full items-center",
timelineType === "detail" timelineType === "detail" && isDesktop
? "flex-col" ? "flex-col"
: mainCameraAspect == "tall" : mainCameraAspect == "tall"
? "flex-row justify-evenly" ? "flex-row justify-evenly"
@ -782,7 +789,7 @@ export function RecordingView({
? "h-full w-72 flex-col" ? "h-full w-72 flex-col"
: `h-28 w-full`, : `h-28 w-full`,
previewRowOverflows ? "" : "items-center justify-center", previewRowOverflows ? "" : "items-center justify-center",
timelineType == "detail" && "mt-4", timelineType == "detail" && isDesktop && "mt-4",
)} )}
> >
<div className="w-2" /> <div className="w-2" />
@ -847,6 +854,7 @@ export function RecordingView({
setScrubbing={setScrubbing} setScrubbing={setScrubbing}
setExportRange={setExportRange} setExportRange={setExportRange}
onAnalysisOpen={onAnalysisOpen} onAnalysisOpen={onAnalysisOpen}
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
/> />
</div> </div>
</div> </div>
@ -864,6 +872,7 @@ type TimelineProps = {
activeReviewItem?: ReviewSegment; activeReviewItem?: ReviewSegment;
currentTime: number; currentTime: number;
exportRange?: TimeRange; exportRange?: TimeRange;
isPlaying?: boolean;
setCurrentTime: React.Dispatch<React.SetStateAction<number>>; setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
manuallySetCurrentTime: (time: number, force: boolean) => void; manuallySetCurrentTime: (time: number, force: boolean) => void;
setScrubbing: React.Dispatch<React.SetStateAction<boolean>>; setScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
@ -880,6 +889,7 @@ function Timeline({
activeReviewItem, activeReviewItem,
currentTime, currentTime,
exportRange, exportRange,
isPlaying,
setCurrentTime, setCurrentTime,
manuallySetCurrentTime, manuallySetCurrentTime,
setScrubbing, setScrubbing,
@ -966,15 +976,19 @@ function Timeline({
"relative", "relative",
isDesktop isDesktop
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "detail" ? "w-[30%]" : "w-60"} no-scrollbar overflow-y-auto` ? `${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 && ( {isMobile && (
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} /> <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> {timelineType != "detail" && (
<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> <>
<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" ? ( {timelineType == "timeline" ? (
!isLoading ? ( !isLoading ? (
<MotionReviewTimeline <MotionReviewTimeline
@ -1009,6 +1023,7 @@ function Timeline({
manuallySetCurrentTime(timestamp, play ?? true) manuallySetCurrentTime(timestamp, play ?? true)
} }
reviewItems={mainCameraReviewItems} reviewItems={mainCameraReviewItems}
isPlaying={isPlaying}
/> />
) : ( ) : (
<div className="scrollbar-container h-full overflow-auto bg-secondary"> <div className="scrollbar-container h-full overflow-auto bg-secondary">