mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
92ac025e43
commit
66d0ad5803
@ -241,6 +241,8 @@ export default function ReviewFilterGroup({
|
|||||||
mode="none"
|
mode="none"
|
||||||
setMode={() => {}}
|
setMode={() => {}}
|
||||||
setRange={() => {}}
|
setRange={() => {}}
|
||||||
|
showExportPreview={false}
|
||||||
|
setShowExportPreview={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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")}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
52
web/src/components/player/GenericVideoPlayer.tsx
Normal file
52
web/src/components/player/GenericVideoPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
45
web/src/hooks/use-video-dimensions.ts
Normal file
45
web/src/hooks/use-video-dimensions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user