diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 6d3ee010a..a52755e6c 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -241,6 +241,8 @@ export default function ReviewFilterGroup({ mode="none" setMode={() => {}} setRange={() => {}} + showExportPreview={false} + setShowExportPreview={() => {}} /> )} diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index c9018c579..577415420 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -22,10 +23,13 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; -import { isDesktop, isIOS } from "react-device-detect"; +import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; import { getUTCOffset } from "@/utils/dateUtil"; +import { baseUrl } from "@/api/baseUrl"; +import { cn } from "@/lib/utils"; +import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; const EXPORT_OPTIONS = [ "1", @@ -44,8 +48,10 @@ type ExportDialogProps = { currentTime: number; range?: TimeRange; mode: ExportMode; + showPreview: boolean; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; + setShowPreview: (showPreview: boolean) => void; }; export default function ExportDialog({ camera, @@ -53,10 +59,13 @@ export default function ExportDialog({ currentTime, range, mode, + showPreview, setRange, setMode, + setShowPreview, }: ExportDialogProps) { const [name, setName] = useState(""); + const onStartExport = useCallback(() => { if (!range) { toast.error("No valid time range selected", { position: "top-center" }); @@ -109,9 +118,16 @@ export default function ExportDialog({ return ( <> + setShowPreview(true)} onSave={() => onStartExport()} onCancel={() => setMode("none")} /> @@ -525,3 +541,44 @@ function CustomTimeSelector({ ); } + +type ExportPreviewDialogProps = { + camera: string; + range?: TimeRange; + showPreview: boolean; + setShowPreview: (showPreview: boolean) => void; +}; + +export function ExportPreviewDialog({ + camera, + range, + showPreview, + setShowPreview, +}: ExportPreviewDialogProps) { + if (!range) { + return null; + } + + const source = `${baseUrl}vod/${camera}/start/${range.after}/end/${range.before}/index.m3u8`; + + return ( + + + + Preview Export + + Preview Export + + + + + + ); +} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index c9879b8cb..fe0e13c11 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -3,7 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Button } from "../ui/button"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; import { TimeRange } from "@/types/timeline"; -import { ExportContent } from "./ExportDialog"; +import { ExportContent, ExportPreviewDialog } from "./ExportDialog"; import { ExportMode } from "@/types/filter"; import ReviewActivityCalendar from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; @@ -34,12 +34,14 @@ type MobileReviewSettingsDrawerProps = { currentTime: number; range?: TimeRange; mode: ExportMode; + showExportPreview: boolean; reviewSummary?: ReviewSummary; allLabels: string[]; allZones: string[]; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; + setShowExportPreview: (showPreview: boolean) => void; }; export default function MobileReviewSettingsDrawer({ features = DEFAULT_DRAWER_FEATURES, @@ -50,12 +52,14 @@ export default function MobileReviewSettingsDrawer({ currentTime, range, mode, + showExportPreview, reviewSummary, allLabels, allZones, onUpdateFilter, setRange, setMode, + setShowExportPreview, }: MobileReviewSettingsDrawerProps) { const [drawerMode, setDrawerMode] = useState("none"); @@ -282,6 +286,13 @@ export default function MobileReviewSettingsDrawer({ show={mode == "timeline"} onSave={() => onStartExport()} onCancel={() => setMode("none")} + onPreview={() => setShowExportPreview(true)} + /> + void; onSave: () => void; onCancel: () => void; }; export default function SaveExportOverlay({ className, show, + onPreview, onSave, onCancel, }: SaveExportOverlayProps) { @@ -24,6 +26,22 @@ export default function SaveExportOverlay({ "mx-auto mt-5 text-center", )} > + + + Cancel + + + + Preview Export + Save Export - - - Cancel - ); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index a04d5b8c1..45c0659d8 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -6,7 +6,7 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getIconForLabel } from "@/utils/iconUtil"; import { useApiHost } from "@/api"; import { Button } from "../../ui/button"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import axios from "axios"; import { toast } from "sonner"; import { Textarea } from "../../ui/textarea"; @@ -21,7 +21,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Event } from "@/types/event"; -import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; @@ -62,8 +61,7 @@ import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { Card, CardContent } from "@/components/ui/card"; import useImageLoaded from "@/hooks/use-image-loaded"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; -import { useResizeObserver } from "@/hooks/resize-observer"; -import { VideoResolutionType } from "@/types/live"; +import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer"; const SEARCH_TABS = [ "details", @@ -599,99 +597,45 @@ function ObjectSnapshotTab({ type VideoTabProps = { search: SearchResult; }; -function VideoTab({ search }: VideoTabProps) { - const [isLoading, setIsLoading] = useState(true); - const videoRef = useRef(null); - - const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]); +export function VideoTab({ search }: VideoTabProps) { const navigate = useNavigate(); const { data: reviewItem } = useSWR([ `review/event/${search.id}`, ]); + const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]); - const containerRef = useRef(null); - - const [{ width: containerWidth, height: containerHeight }] = - useResizeObserver(containerRef); - const [videoResolution, setVideoResolution] = useState({ - width: 0, - height: 0, - }); - - const videoAspectRatio = useMemo(() => { - return videoResolution.width / videoResolution.height || 16 / 9; - }, [videoResolution]); - - const containerAspectRatio = useMemo(() => { - return containerWidth / containerHeight || 16 / 9; - }, [containerWidth, containerHeight]); - - const videoDimensions = useMemo(() => { - if (!containerWidth || !containerHeight) - return { width: "100%", height: "100%" }; - - if (containerAspectRatio > videoAspectRatio) { - const height = containerHeight; - const width = height * videoAspectRatio; - return { width: `${width}px`, height: `${height}px` }; - } else { - const width = containerWidth; - const height = width / videoAspectRatio; - return { width: `${width}px`, height: `${height}px` }; - } - }, [containerWidth, containerHeight, videoAspectRatio, containerAspectRatio]); + const source = `${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`; return ( - - - {(isLoading || !reviewItem) && ( - - )} + + {reviewItem && ( - setIsLoading(false)} - setFullResolution={setVideoResolution} - /> - {!isLoading && reviewItem && ( - - - - { - if (reviewItem?.id) { - const params = new URLSearchParams({ - id: reviewItem.id, - }).toString(); - navigate(`/review?${params}`); - } - }} - > - - - - View in History - - + className={cn( + "absolute top-2 z-10 flex items-center", + isIOS ? "right-8" : "right-2", )} + > + + + { + if (reviewItem?.id) { + const params = new URLSearchParams({ + id: reviewItem.id, + }).toString(); + navigate(`/review?${params}`); + } + }} + > + + + + View in History + - - + )} + ); } diff --git a/web/src/components/player/GenericVideoPlayer.tsx b/web/src/components/player/GenericVideoPlayer.tsx new file mode 100644 index 000000000..75f56e96f --- /dev/null +++ b/web/src/components/player/GenericVideoPlayer.tsx @@ -0,0 +1,52 @@ +import React, { useState, useRef } from "react"; +import { useVideoDimensions } from "@/hooks/use-video-dimensions"; +import HlsVideoPlayer from "./HlsVideoPlayer"; +import ActivityIndicator from "../indicators/activity-indicator"; + +type GenericVideoPlayerProps = { + source: string; + onPlaying?: () => void; + children?: React.ReactNode; +}; + +export function GenericVideoPlayer({ + source, + onPlaying, + children, +}: GenericVideoPlayerProps) { + const [isLoading, setIsLoading] = useState(true); + const videoRef = useRef(null); + const containerRef = useRef(null); + const { videoDimensions, setVideoResolution } = + useVideoDimensions(containerRef); + + return ( + + + {isLoading && ( + + )} + + { + setIsLoading(false); + onPlaying?.(); + }} + setFullResolution={setVideoResolution} + /> + {!isLoading && children} + + + + ); +} diff --git a/web/src/hooks/use-video-dimensions.ts b/web/src/hooks/use-video-dimensions.ts new file mode 100644 index 000000000..448dd5078 --- /dev/null +++ b/web/src/hooks/use-video-dimensions.ts @@ -0,0 +1,45 @@ +import { useState, useMemo } from "react"; +import { useResizeObserver } from "./resize-observer"; + +export type VideoResolutionType = { + width: number; + height: number; +}; + +export function useVideoDimensions( + containerRef: React.RefObject, +) { + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(containerRef); + const [videoResolution, setVideoResolution] = useState({ + width: 0, + height: 0, + }); + + const videoAspectRatio = useMemo(() => { + return videoResolution.width / videoResolution.height || 16 / 9; + }, [videoResolution]); + + const containerAspectRatio = useMemo(() => { + return containerWidth / containerHeight || 16 / 9; + }, [containerWidth, containerHeight]); + + const videoDimensions = useMemo(() => { + if (!containerWidth || !containerHeight) + return { width: "100%", height: "100%" }; + if (containerAspectRatio > videoAspectRatio) { + const height = containerHeight; + const width = height * videoAspectRatio; + return { width: `${width}px`, height: `${height}px` }; + } else { + const width = containerWidth; + const height = width / videoAspectRatio; + return { width: `${width}px`, height: `${height}px` }; + } + }, [containerWidth, containerHeight, videoAspectRatio, containerAspectRatio]); + + return { + videoDimensions, + setVideoResolution, + }; +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 0c59cef38..535c412d4 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -140,6 +140,7 @@ export function RecordingView({ const [exportMode, setExportMode] = useState("none"); const [exportRange, setExportRange] = useState(); + const [showExportPreview, setShowExportPreview] = useState(false); // move to next clip @@ -412,6 +413,7 @@ export function RecordingView({ latestTime={timeRange.before} mode={exportMode} range={exportRange} + showPreview={showExportPreview} setRange={(range) => { setExportRange(range); @@ -420,6 +422,7 @@ export function RecordingView({ } }} setMode={setExportMode} + setShowPreview={setShowExportPreview} /> )} {isDesktop && ( @@ -473,11 +476,13 @@ export function RecordingView({ latestTime={timeRange.before} mode={exportMode} range={exportRange} + showExportPreview={showExportPreview} allLabels={reviewFilterList.labels} allZones={reviewFilterList.zones} onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode} + setShowExportPreview={setShowExportPreview} />