diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 732533ef2..c393a8bc8 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -19,10 +19,11 @@ "noFoundForTimePeriod": "No events found for this time period." }, "detail": { + "label": "Detail", "noDataFound": "No detail data to review", "aria": "Toggle detail view", - "trackedObject_one": "tracked object", - "trackedObject_other": "tracked objects", + "trackedObject_one": "object", + "trackedObject_other": "objects", "noObjectDetailData": "No object detail data available." }, "objectTrack": { diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index f35cfdc1d..8ba170882 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -194,6 +194,12 @@ }, "deleteTrackedObject": { "label": "Delete this tracked object" + }, + "showObjectDetails": { + "label": "Show object path" + }, + "hideObjectDetails": { + "label": "Hide object path" } }, "dialog": { diff --git a/web/src/components/overlay/MobileTimelineDrawer.tsx b/web/src/components/overlay/MobileTimelineDrawer.tsx index ed71f8a23..1d660f928 100644 --- a/web/src/components/overlay/MobileTimelineDrawer.tsx +++ b/web/src/components/overlay/MobileTimelineDrawer.tsx @@ -51,6 +51,15 @@ export default function MobileTimelineDrawer({ > {t("events.label")} +
{ + onSelect("detail"); + setDrawer(false); + }} + > + {t("detail.label")} +
); diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index 9e9dae904..6669522ae 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,26 +16,24 @@ 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"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 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 +51,10 @@ export default function DetailStream({ const effectiveTime = currentTime + annotationOffset / 1000; const [upload, setUpload] = useState(undefined); + const onSeekCheckPlaying = (timestamp: number) => { + 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,20 @@ 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; + + if (isFullyInvisible) { + setProgrammaticScroll(); + scrollIntoView(element, { + scrollMode: "if-needed", + behavior: "smooth", + }); + } } } }, [ @@ -134,6 +144,7 @@ export default function DetailStream({ annotationOffset, userInteracting, setProgrammaticScroll, + isPlaying, ]); // Auto-select active review based on effectiveTime (if inside a review range) @@ -165,9 +176,9 @@ export default function DetailStream({
-
+
{reviewItems?.length === 0 ? (
{t("detail.noDataFound")} @@ -181,7 +192,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 +231,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 +246,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 +271,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")} - - ))} +
{ + e.stopPropagation(); + 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) => { + (fetchedEvents || []).map((event, index) => { return ( - +
+ +
); }) )} @@ -337,11 +383,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 +402,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,91 +443,100 @@ 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( + event.id == 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 ?? ""} -
-
+
onOpenUpload?.(e)} + selectedObjectId={selectedObjectId} + setSelectedObjectId={handleObjectSelect} />
- -
- - - -
- -
- -
-
+ +
+ +
- + ); } type LifecycleItemProps = { - event: ObjectLifecycleSequence; + item: ObjectLifecycleSequence; isActive?: boolean; onSeek?: (timestamp: number, play?: boolean) => void; + effectiveTime?: number; }; -function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) { +function LifecycleItem({ + item, + isActive, + onSeek, + effectiveTime, +}: LifecycleItemProps) { const { t } = useTranslation("views/events"); const { data: config } = useSWR("config"); + const aspectRatio = useMemo(() => { + if (!config || !item?.camera) { + return 16 / 9; + } + + return ( + config.cameras[item.camera].detect.width / + config.cameras[item.camera].detect.height + ); + }, [config, item]); + const formattedEventTimestamp = config - ? formatUnixTimestampToDateTime(event.timestamp ?? 0, { + ? formatUnixTimestampToDateTime(item?.timestamp ?? 0, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" @@ -519,11 +551,28 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) { }) : ""; + const ratio = + Array.isArray(item?.data.box) && item?.data.box.length >= 4 + ? (aspectRatio * (item?.data.box[2] / item?.data.box[3])).toFixed(2) + : "N/A"; + const areaPx = + Array.isArray(item?.data.box) && item?.data.box.length >= 4 + ? Math.round( + (config?.cameras[item?.camera]?.detect?.width ?? 0) * + (config?.cameras[item?.camera]?.detect?.height ?? 0) * + (item?.data.box[2] * item?.data.box[3]), + ) + : undefined; + const areaPct = + Array.isArray(item?.data.box) && item?.data.box.length >= 4 + ? (item?.data.box[2] * item?.data.box[3]).toFixed(4) + : undefined; + return (
{ - onSeek?.(event.timestamp ?? 0, false); + onSeek?.(item.timestamp ?? 0, false); }} className={cn( "flex cursor-pointer items-center gap-2 text-sm text-primary-variant", @@ -532,11 +581,46 @@ function LifecycleItem({ event, isActive, onSeek }: LifecycleItemProps) { : "duration-500", )} > -
- +
+ = (item?.timestamp ?? 0)) && + "fill-selected duration-300", + )} + />
-
{getLifecycleItemDescription(event)}
+ + + {getLifecycleItemDescription(item)} + + +
+
+
+ + {t("objectLifecycle.lifecycleItemDesc.header.ratio")} + + {ratio} +
+ +
+ + {t("objectLifecycle.lifecycleItemDesc.header.area")} + + {areaPx !== undefined && areaPct !== undefined ? ( + + {areaPx} {t("pixels", { ns: "common" })} · {areaPct}% + + ) : ( + N/A + )} +
+
+
+
+
{formattedEventTimestamp}
@@ -561,8 +645,8 @@ function ObjectTimeline({ }, ]); - if ((!timeline || timeline.length === 0) && isValidating) { - return ; + if (isValidating && (!timeline || timeline.length === 0)) { + return ; } if (!timeline || timeline.length === 0) { @@ -573,20 +657,75 @@ function ObjectTimeline({ ); } + // Calculate how far down the blue line should extend based on effectiveTime + const calculateLineHeight = () => { + if (!timeline || timeline.length === 0) return 0; + + const currentTime = effectiveTime ?? 0; + + // Find which events have been passed + let lastPassedIndex = -1; + for (let i = 0; i < timeline.length; i++) { + if (currentTime >= (timeline[i].timestamp ?? 0)) { + lastPassedIndex = i; + } else { + break; + } + } + + // No events passed yet + if (lastPassedIndex < 0) return 0; + + // All events passed + if (lastPassedIndex >= timeline.length - 1) return 100; + + // Calculate percentage based on item position, not time + // Each item occupies an equal visual space regardless of time gaps + const itemPercentage = 100 / (timeline.length - 1); + + // Find progress between current and next event for smooth transition + const currentEvent = timeline[lastPassedIndex]; + const nextEvent = timeline[lastPassedIndex + 1]; + const currentTimestamp = currentEvent.timestamp ?? 0; + const nextTimestamp = nextEvent.timestamp ?? 0; + + // Calculate interpolation between the two events + const timeBetween = nextTimestamp - currentTimestamp; + const timeElapsed = currentTime - currentTimestamp; + const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0; + + // Base position plus interpolated progress to next item + return Math.min( + 100, + lastPassedIndex * itemPercentage + interpolation * itemPercentage, + ); + }; + + 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/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 8b1ef2ff4..4427b59ac 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -1,6 +1,7 @@ import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns"; import { Locale } from "date-fns/locale"; import { formatInTimeZone } from "date-fns-tz"; +import i18n from "@/utils/i18n"; export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; export const dateToLong = (date: Date): number => epochToLong(date.getTime()); @@ -234,11 +235,13 @@ export const formatUnixTimestampToDateTime = ( * If end time is not provided, it returns 'In Progress' * @param start_time: number - Unix timestamp for start time * @param end_time: number|null - Unix timestamp for end time + * @param abbreviated: boolean - Whether to use abbreviated forms (h, m, s) instead of full words * @returns string - duration or 'In Progress' if end time is not provided */ export const getDurationFromTimestamps = ( start_time: number, end_time: number | null, + abbreviated: boolean = false, ): string => { if (isNaN(start_time)) { return "Invalid start time"; @@ -250,12 +253,39 @@ export const getDurationFromTimestamps = ( } const start = fromUnixTime(start_time); const end = fromUnixTime(end_time); - duration = formatDuration(intervalToDuration({ start, end }), { - format: ["hours", "minutes", "seconds"], - }) - .replace("hours", "h") - .replace("minutes", "m") - .replace("seconds", "s"); + const durationObj = intervalToDuration({ start, end }); + + // Build duration string using i18n keys or abbreviations + const parts: string[] = []; + if (durationObj.hours) { + const count = durationObj.hours; + if (abbreviated) { + parts.push(`${count}h`); + } else { + const key = count === 1 ? "hour_one" : "hour_other"; + parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" })); + } + } + if (durationObj.minutes) { + const count = durationObj.minutes; + if (abbreviated) { + parts.push(`${count}m`); + } else { + const key = count === 1 ? "minute_one" : "minute_other"; + parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" })); + } + } + if (durationObj.seconds) { + const count = durationObj.seconds; + if (abbreviated) { + parts.push(`${count}s`); + } else { + const key = count === 1 ? "second_one" : "second_other"; + parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" })); + } + } + + duration = parts.join(" "); } return duration; }; diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts index 0a716d9fc..edb46b969 100644 --- a/web/src/utils/lifecycleUtil.ts +++ b/web/src/utils/lifecycleUtil.ts @@ -9,7 +9,9 @@ export function getLifecycleItemDescription( ? lifecycleItem.data.sub_label[0] : lifecycleItem.data.sub_label || lifecycleItem.data.label; - const label = getTranslatedLabel(rawLabel); + const label = lifecycleItem.data.sub_label + ? rawLabel + : getTranslatedLabel(rawLabel); switch (lifecycleItem.class_type) { case "visible": @@ -44,14 +46,18 @@ export function getLifecycleItemDescription( { ns: "views/explore", label, - attribute: lifecycleItem.data.attribute.replaceAll("_", " "), + attribute: getTranslatedLabel( + lifecycleItem.data.attribute.replaceAll("_", " "), + ), }, ); } else { title = t("objectLifecycle.lifecycleItemDesc.attribute.other", { ns: "views/explore", label: lifecycleItem.data.label, - attribute: lifecycleItem.data.attribute.replaceAll("_", " "), + attribute: getTranslatedLabel( + lifecycleItem.data.attribute.replaceAll("_", " "), + ), }); } return title; diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aee60b6b2..4e0aa83ae 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -696,7 +696,7 @@ export function RecordingView({
@@ -847,6 +847,7 @@ export function RecordingView({ setScrubbing={setScrubbing} setExportRange={setExportRange} onAnalysisOpen={onAnalysisOpen} + isPlaying={mainControllerRef?.current?.isPlaying() ?? false} />
@@ -864,6 +865,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 +882,7 @@ function Timeline({ activeReviewItem, currentTime, exportRange, + isPlaying, setCurrentTime, manuallySetCurrentTime, setScrubbing, @@ -966,15 +969,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 ? ( ) : (