See a preview when using the timeline to export footage (#14321)

* custom hook and generic video player component

* add export preview dialog

* export preview dialog when using timeline export

* refactor search detail dialog to use new generic video player component

* clean up
This commit is contained in:
Josh Hawkins 2024-10-13 12:46:40 -05:00 committed by GitHub
parent 92ac025e43
commit 66d0ad5803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 224 additions and 98 deletions

View File

@ -241,6 +241,8 @@ export default function ReviewFilterGroup({
mode="none" mode="none"
setMode={() => {}} setMode={() => {}}
setRange={() => {}} setRange={() => {}}
showExportPreview={false}
setShowExportPreview={() => {}}
/> />
)} )}
</div> </div>

View File

@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@ -22,10 +23,13 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select"; 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 { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay"; import SaveExportOverlay from "./SaveExportOverlay";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
const EXPORT_OPTIONS = [ const EXPORT_OPTIONS = [
"1", "1",
@ -44,8 +48,10 @@ type ExportDialogProps = {
currentTime: number; currentTime: number;
range?: TimeRange; range?: TimeRange;
mode: ExportMode; mode: ExportMode;
showPreview: boolean;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
setShowPreview: (showPreview: boolean) => void;
}; };
export default function ExportDialog({ export default function ExportDialog({
camera, camera,
@ -53,10 +59,13 @@ export default function ExportDialog({
currentTime, currentTime,
range, range,
mode, mode,
showPreview,
setRange, setRange,
setMode, setMode,
setShowPreview,
}: ExportDialogProps) { }: ExportDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const onStartExport = useCallback(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
toast.error("No valid time range selected", { position: "top-center" }); toast.error("No valid time range selected", { position: "top-center" });
@ -109,9 +118,16 @@ export default function ExportDialog({
return ( return (
<> <>
<ExportPreviewDialog
camera={camera}
range={range}
showPreview={showPreview}
setShowPreview={setShowPreview}
/>
<SaveExportOverlay <SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"} show={mode == "timeline"}
onPreview={() => setShowPreview(true)}
onSave={() => onStartExport()} onSave={() => onStartExport()}
onCancel={() => setMode("none")} onCancel={() => setMode("none")}
/> />
@ -525,3 +541,44 @@ function CustomTimeSelector({
</div> </div>
); );
} }
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 (
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isMobile && "px-4",
)}
>
<DialogHeader>
<DialogTitle>Preview Export</DialogTitle>
<DialogDescription className="sr-only">
Preview Export
</DialogDescription>
</DialogHeader>
<GenericVideoPlayer source={source} />
</DialogContent>
</Dialog>
);
}

View File

@ -3,7 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { ExportContent } from "./ExportDialog"; import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import { ExportMode } from "@/types/filter"; import { ExportMode } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar"; import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select"; import { SelectSeparator } from "../ui/select";
@ -34,12 +34,14 @@ type MobileReviewSettingsDrawerProps = {
currentTime: number; currentTime: number;
range?: TimeRange; range?: TimeRange;
mode: ExportMode; mode: ExportMode;
showExportPreview: boolean;
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
allLabels: string[]; allLabels: string[];
allZones: string[]; allZones: string[];
onUpdateFilter: (filter: ReviewFilter) => void; onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
setShowExportPreview: (showPreview: boolean) => void;
}; };
export default function MobileReviewSettingsDrawer({ export default function MobileReviewSettingsDrawer({
features = DEFAULT_DRAWER_FEATURES, features = DEFAULT_DRAWER_FEATURES,
@ -50,12 +52,14 @@ export default function MobileReviewSettingsDrawer({
currentTime, currentTime,
range, range,
mode, mode,
showExportPreview,
reviewSummary, reviewSummary,
allLabels, allLabels,
allZones, allZones,
onUpdateFilter, onUpdateFilter,
setRange, setRange,
setMode, setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) { }: MobileReviewSettingsDrawerProps) {
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none"); const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
@ -282,6 +286,13 @@ export default function MobileReviewSettingsDrawer({
show={mode == "timeline"} show={mode == "timeline"}
onSave={() => onStartExport()} onSave={() => onStartExport()}
onCancel={() => setMode("none")} onCancel={() => setMode("none")}
onPreview={() => setShowExportPreview(true)}
/>
<ExportPreviewDialog
camera={camera}
range={range}
showPreview={showExportPreview}
setShowPreview={setShowExportPreview}
/> />
<Drawer <Drawer
modal={!(isIOS && drawerMode == "export")} modal={!(isIOS && drawerMode == "export")}

View File

@ -1,4 +1,4 @@
import { LuX } from "react-icons/lu"; import { LuVideo, LuX } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -6,12 +6,14 @@ import { cn } from "@/lib/utils";
type SaveExportOverlayProps = { type SaveExportOverlayProps = {
className: string; className: string;
show: boolean; show: boolean;
onPreview: () => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
}; };
export default function SaveExportOverlay({ export default function SaveExportOverlay({
className, className,
show, show,
onPreview,
onSave, onSave,
onCancel, onCancel,
}: SaveExportOverlayProps) { }: SaveExportOverlayProps) {
@ -24,6 +26,22 @@ export default function SaveExportOverlay({
"mx-auto mt-5 text-center", "mx-auto mt-5 text-center",
)} )}
> >
<Button
className="flex items-center gap-1 text-primary"
size="sm"
onClick={onCancel}
>
<LuX />
Cancel
</Button>
<Button
className="flex items-center gap-1"
size="sm"
onClick={onPreview}
>
<LuVideo />
Preview Export
</Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
variant="select" variant="select"
@ -33,14 +51,6 @@ export default function SaveExportOverlay({
<FaCompactDisc /> <FaCompactDisc />
Save Export Save Export
</Button> </Button>
<Button
className="flex items-center gap-1 text-primary"
size="sm"
onClick={onCancel}
>
<LuX />
Cancel
</Button>
</div> </div>
</div> </div>
); );

View File

@ -6,7 +6,7 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { Button } from "../../ui/button"; 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 axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Textarea } from "../../ui/textarea"; import { Textarea } from "../../ui/textarea";
@ -21,7 +21,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator"; 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 { Card, CardContent } from "@/components/ui/card";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { useResizeObserver } from "@/hooks/resize-observer"; import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer";
import { VideoResolutionType } from "@/types/live";
const SEARCH_TABS = [ const SEARCH_TABS = [
"details", "details",
@ -599,99 +597,45 @@ function ObjectSnapshotTab({
type VideoTabProps = { type VideoTabProps = {
search: SearchResult; search: SearchResult;
}; };
function VideoTab({ search }: VideoTabProps) {
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]);
export function VideoTab({ search }: VideoTabProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: reviewItem } = useSWR<ReviewSegment>([ const { data: reviewItem } = useSWR<ReviewSegment>([
`review/event/${search.id}`, `review/event/${search.id}`,
]); ]);
const endTime = useMemo(() => search.end_time ?? Date.now() / 1000, [search]);
const containerRef = useRef<HTMLDivElement | null>(null); const source = `${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`;
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const [videoResolution, setVideoResolution] = useState<VideoResolutionType>({
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 ( return (
<div ref={containerRef} className="relative flex h-full w-full flex-col"> <GenericVideoPlayer source={source}>
<div className="relative flex flex-grow items-center justify-center"> {reviewItem && (
{(isLoading || !reviewItem) && (
<ActivityIndicator className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2" />
)}
<div <div
className="relative flex items-center justify-center" className={cn(
style={videoDimensions} "absolute top-2 z-10 flex items-center",
> isIOS ? "right-8" : "right-2",
<HlsVideoPlayer
videoRef={videoRef}
currentSource={`${baseUrl}vod/${search.camera}/start/${search.start_time}/end/${endTime}/index.m3u8`}
hotKeys
visible
frigateControls={false}
fullscreen={false}
supportsFullscreen={false}
onPlaying={() => setIsLoading(false)}
setFullResolution={setVideoResolution}
/>
{!isLoading && reviewItem && (
<div
className={cn(
"absolute top-2 z-10 flex items-center",
isIOS ? "right-8" : "right-2",
)}
>
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (reviewItem?.id) {
const params = new URLSearchParams({
id: reviewItem.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent side="left">View in History</TooltipContent>
</Tooltip>
</div>
)} )}
>
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (reviewItem?.id) {
const params = new URLSearchParams({
id: reviewItem.id,
}).toString();
navigate(`/review?${params}`);
}
}}
>
<FaHistory className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent side="left">View in History</TooltipContent>
</Tooltip>
</div> </div>
</div> )}
</div> </GenericVideoPlayer>
); );
} }

View File

@ -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<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { videoDimensions, setVideoResolution } =
useVideoDimensions(containerRef);
return (
<div ref={containerRef} className="relative flex h-full w-full flex-col">
<div className="relative flex flex-grow items-center justify-center">
{isLoading && (
<ActivityIndicator className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2" />
)}
<div
className="relative flex items-center justify-center"
style={videoDimensions}
>
<HlsVideoPlayer
videoRef={videoRef}
currentSource={source}
hotKeys
visible
frigateControls={false}
fullscreen={false}
supportsFullscreen={false}
onPlaying={() => {
setIsLoading(false);
onPlaying?.();
}}
setFullResolution={setVideoResolution}
/>
{!isLoading && children}
</div>
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>,
) {
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const [videoResolution, setVideoResolution] = useState<VideoResolutionType>({
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,
};
}

View File

@ -140,6 +140,7 @@ export function RecordingView({
const [exportMode, setExportMode] = useState<ExportMode>("none"); const [exportMode, setExportMode] = useState<ExportMode>("none");
const [exportRange, setExportRange] = useState<TimeRange>(); const [exportRange, setExportRange] = useState<TimeRange>();
const [showExportPreview, setShowExportPreview] = useState(false);
// move to next clip // move to next clip
@ -412,6 +413,7 @@ export function RecordingView({
latestTime={timeRange.before} latestTime={timeRange.before}
mode={exportMode} mode={exportMode}
range={exportRange} range={exportRange}
showPreview={showExportPreview}
setRange={(range) => { setRange={(range) => {
setExportRange(range); setExportRange(range);
@ -420,6 +422,7 @@ export function RecordingView({
} }
}} }}
setMode={setExportMode} setMode={setExportMode}
setShowPreview={setShowExportPreview}
/> />
)} )}
{isDesktop && ( {isDesktop && (
@ -473,11 +476,13 @@ export function RecordingView({
latestTime={timeRange.before} latestTime={timeRange.before}
mode={exportMode} mode={exportMode}
range={exportRange} range={exportRange}
showExportPreview={showExportPreview}
allLabels={reviewFilterList.labels} allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones} allZones={reviewFilterList.zones}
onUpdateFilter={updateFilter} onUpdateFilter={updateFilter}
setRange={setExportRange} setRange={setExportRange}
setMode={setExportMode} setMode={setExportMode}
setShowExportPreview={setShowExportPreview}
/> />
</div> </div>
</div> </div>