diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index d4d7d4a2b..801125cb2 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -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 { diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 9e9dae904..5d23abdc2 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -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("config"); @@ -54,6 +50,11 @@ export default function DetailStream({ const effectiveTime = currentTime + annotationOffset / 1000; const [upload, setUpload] = useState(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({
-
+
{reviewItems?.length === 0 ? (
{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( + const { data: fetchedEvents, isValidating } = useSWR( 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 (
-
-
+
+
{displayTime}
- {reviewDuration && ( -
- {reviewDuration} +
+ {iconLabels.slice(0, 5).map((lbl, idx) => ( +
+ {getIconForLabel(lbl, "size-3 text-primary dark:text-white")} +
+ ))} +
+
+
+ {review.data.metadata?.title && ( +
+ {review.data.metadata.title}
)} -
{reviewInfo}
+
+
{reviewInfo}
+ + {reviewDuration && ( + <> + +
+ {reviewDuration} +
+ + )} +
-
- {iconLabels.slice(0, 5).map((lbl, idx) => ( - - {getIconForLabel(lbl, "size-4 text-primary dark:text-white")} - - ))} +
{ + 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 ? ( + + ) : ( + + )}
- {isActive && ( -
- {shouldFetchEvents && !fetchedEvents ? ( + {open && ( +
+ {shouldFetchEvents && isValidating && !fetchedEvents ? ( ) : ( (fetchedEvents || []).map((event) => { return ( - + <> +
+ +
+ ); }) )} @@ -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" > -
- {getIconForLabel( - audioLabel, - "size-4 text-primary dark:text-white", - )} +
+
+ {getIconForLabel( + audioLabel, + "size-3 text-primary dark:text-white", + )} +
{getTranslatedLabel(audioLabel)}
@@ -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("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 ( - setOpen(o)}> + <>
= (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", )} > -
+
{ 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", - )} +
+ {getIconForLabel( + event.label, + "size-3 text-primary dark:text-white", + )} +
{getTranslatedLabel(event.label)} - - {formattedStart ?? ""} - {formattedEnd ?? ""} -
@@ -460,36 +487,21 @@ function EventCollapsible({ event={event} config={config} onOpenUpload={(e) => onOpenUpload?.(e)} + selectedObjectId={selectedObjectId} + setSelectedObjectId={handleObjectSelect} />
- -
- - - -
- -
- -
-
+ +
+ +
- + ); } @@ -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("config"); @@ -532,8 +550,14 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) { : "duration-500", )} > -
- +
+ = (event.timestamp ?? 0)) && + "fill-selected duration-300", + )} + />
{getLifecycleItemDescription(event)}
@@ -561,8 +585,8 @@ function ObjectTimeline({ }, ]); - if ((!timeline || timeline.length === 0) && isValidating) { - return ; + if (isValidating && (!timeline || timeline.length === 0)) { + return ; } 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 ( -
- {timeline.map((event, idx) => { - const isActive = - Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; - return ( - - ); - })} +
+
+
+
+ {timeline.map((event, idx) => { + const isActive = + Math.abs((effectiveTime ?? 0) - (event.timestamp ?? 0)) <= 0.5; + + return ( + + ); + })} +
); } diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index 4723d2b13..ac98a8ebc 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -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 ( - - - - - - - { - navigate(`/explore?event_id=${event.id}`); - }} - > - {t("details.item.button.viewInExplore")} - - - - {t("itemMenu.downloadSnapshot.label")} - - - - {event.has_snapshot && - event.plus_id == undefined && - event.data.type == "object" && - config?.plus?.enabled && ( - { - onOpenUpload?.(event); - }} - > - {t("itemMenu.submitToPlus.label")} - - )} - - {event.has_snapshot && config?.semantic_search?.enabled && ( + <> + + + +
+ +
+
+ + + + {event.id === selectedObjectId + ? t("itemMenu.hideObjectDetails.label") + : t("itemMenu.showObjectDetails.label")} + + { - 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")} - )} - - -
+ + + {t("itemMenu.downloadSnapshot.label")} + + + + {event.has_snapshot && + event.plus_id == undefined && + event.data.type == "object" && + config?.plus?.enabled && ( + { + onOpenUpload?.(event); + }} + > + {t("itemMenu.submitToPlus.label")} + + )} + + {event.has_snapshot && config?.semantic_search?.enabled && ( + { + if (onOpenSimilarity) onOpenSimilarity(event); + else + navigate( + `/explore?search_type=similarity&event_id=${event.id}`, + ); + }} + > + {t("itemMenu.findSimilar.label")} + + )} +
+
+
+ ); } diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aee60b6b2..3e24c2926 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -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 (
@@ -847,6 +854,7 @@ export function RecordingView({ setScrubbing={setScrubbing} setExportRange={setExportRange} onAnalysisOpen={onAnalysisOpen} + isPlaying={mainControllerRef?.current?.isPlaying() ?? false} />
@@ -864,6 +872,7 @@ type TimelineProps = { activeReviewItem?: ReviewSegment; currentTime: number; exportRange?: TimeRange; + isPlaying?: boolean; setCurrentTime: React.Dispatch>; manuallySetCurrentTime: (time: number, force: boolean) => void; setScrubbing: React.Dispatch>; @@ -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 && ( )} -
-
+ {timelineType != "detail" && ( + <> +
+
+ + )} {timelineType == "timeline" ? ( !isLoading ? ( ) : (