mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	UI fixes (#12490)
* Improve export handling when errors occur * Fix mobile zooming * Handle recordings buffering * Cleanup * Url encode export name * Start with actual name in input * Fix buffering
This commit is contained in:
		
							parent
							
								
									78c15f3020
								
							
						
					
					
						commit
						c56e7e7c6c
					
				| @ -13,7 +13,6 @@ from flask import ( | |||||||
|     request, |     request, | ||||||
| ) | ) | ||||||
| from peewee import DoesNotExist | from peewee import DoesNotExist | ||||||
| from werkzeug.utils import secure_filename |  | ||||||
| 
 | 
 | ||||||
| from frigate.const import EXPORT_DIR | from frigate.const import EXPORT_DIR | ||||||
| from frigate.models import Export, Recordings | from frigate.models import Export, Recordings | ||||||
| @ -48,9 +47,9 @@ def export_recording(camera_name: str, start_time, end_time): | |||||||
| 
 | 
 | ||||||
|     json: dict[str, any] = request.get_json(silent=True) or {} |     json: dict[str, any] = request.get_json(silent=True) or {} | ||||||
|     playback_factor = json.get("playback", "realtime") |     playback_factor = json.get("playback", "realtime") | ||||||
|     name: Optional[str] = json.get("name") |     friendly_name: Optional[str] = json.get("name") | ||||||
| 
 | 
 | ||||||
|     if len(name or "") > 256: |     if len(friendly_name or "") > 256: | ||||||
|         return make_response( |         return make_response( | ||||||
|             jsonify({"success": False, "message": "File name is too long."}), |             jsonify({"success": False, "message": "File name is too long."}), | ||||||
|             401, |             401, | ||||||
| @ -78,7 +77,7 @@ def export_recording(camera_name: str, start_time, end_time): | |||||||
|     exporter = RecordingExporter( |     exporter = RecordingExporter( | ||||||
|         current_app.frigate_config, |         current_app.frigate_config, | ||||||
|         camera_name, |         camera_name, | ||||||
|         secure_filename(name) if name else None, |         friendly_name, | ||||||
|         int(start_time), |         int(start_time), | ||||||
|         int(end_time), |         int(end_time), | ||||||
|         ( |         ( | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -59,7 +59,7 @@ | |||||||
|         "react-tracked": "^2.0.0", |         "react-tracked": "^2.0.0", | ||||||
|         "react-transition-group": "^4.4.5", |         "react-transition-group": "^4.4.5", | ||||||
|         "react-use-websocket": "^4.8.1", |         "react-use-websocket": "^4.8.1", | ||||||
|         "react-zoom-pan-pinch": "^3.6.1", |         "react-zoom-pan-pinch": "3.4.4", | ||||||
|         "recoil": "^0.7.7", |         "recoil": "^0.7.7", | ||||||
|         "scroll-into-view-if-needed": "^3.1.0", |         "scroll-into-view-if-needed": "^3.1.0", | ||||||
|         "sonner": "^1.5.0", |         "sonner": "^1.5.0", | ||||||
| @ -6841,9 +6841,9 @@ | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "node_modules/react-zoom-pan-pinch": { |     "node_modules/react-zoom-pan-pinch": { | ||||||
|       "version": "3.6.1", |       "version": "3.4.4", | ||||||
|       "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.6.1.tgz", |       "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz", | ||||||
|       "integrity": "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==", |       "integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==", | ||||||
|       "license": "MIT", |       "license": "MIT", | ||||||
|       "engines": { |       "engines": { | ||||||
|         "node": ">=8", |         "node": ">=8", | ||||||
|  | |||||||
| @ -65,7 +65,7 @@ | |||||||
|     "react-tracked": "^2.0.0", |     "react-tracked": "^2.0.0", | ||||||
|     "react-transition-group": "^4.4.5", |     "react-transition-group": "^4.4.5", | ||||||
|     "react-use-websocket": "^4.8.1", |     "react-use-websocket": "^4.8.1", | ||||||
|     "react-zoom-pan-pinch": "^3.6.1", |     "react-zoom-pan-pinch": "3.4.4", | ||||||
|     "recoil": "^0.7.7", |     "recoil": "^0.7.7", | ||||||
|     "scroll-into-view-if-needed": "^3.1.0", |     "scroll-into-view-if-needed": "^3.1.0", | ||||||
|     "sonner": "^1.5.0", |     "sonner": "^1.5.0", | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| import ActivityIndicator from "../indicators/activity-indicator"; | import ActivityIndicator from "../indicators/activity-indicator"; | ||||||
| import { LuTrash } from "react-icons/lu"; | import { LuTrash } from "react-icons/lu"; | ||||||
| import { Button } from "../ui/button"; | import { Button } from "../ui/button"; | ||||||
| import { useState } from "react"; | import { useCallback, useState } from "react"; | ||||||
| import { isDesktop } from "react-device-detect"; | import { isDesktop } from "react-device-detect"; | ||||||
| import { FaDownload, FaPlay } from "react-icons/fa"; | import { FaDownload, FaPlay } from "react-icons/fa"; | ||||||
| import Chip from "../indicators/Chip"; | import Chip from "../indicators/Chip"; | ||||||
| @ -47,6 +47,15 @@ export default function ExportCard({ | |||||||
|     update: string; |     update: string; | ||||||
|   }>(); |   }>(); | ||||||
| 
 | 
 | ||||||
|  |   const submitRename = useCallback(() => { | ||||||
|  |     if (editName == undefined) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     onRename(exportedRecording.id, editName.update); | ||||||
|  |     setEditName(undefined); | ||||||
|  |   }, [editName, exportedRecording, onRename, setEditName]); | ||||||
|  | 
 | ||||||
|   useKeyboardListener( |   useKeyboardListener( | ||||||
|     editName != undefined ? ["Enter"] : [], |     editName != undefined ? ["Enter"] : [], | ||||||
|     (key, modifiers) => { |     (key, modifiers) => { | ||||||
| @ -57,8 +66,7 @@ export default function ExportCard({ | |||||||
|         editName && |         editName && | ||||||
|         editName.update.length > 0 |         editName.update.length > 0 | ||||||
|       ) { |       ) { | ||||||
|         onRename(exportedRecording.id, editName.update); |         submitRename(); | ||||||
|         setEditName(undefined); |  | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|   ); |   ); | ||||||
| @ -84,7 +92,7 @@ export default function ExportCard({ | |||||||
|                 className="mt-3" |                 className="mt-3" | ||||||
|                 type="search" |                 type="search" | ||||||
|                 placeholder={editName?.original} |                 placeholder={editName?.original} | ||||||
|                 value={editName?.update} |                 value={editName?.update || editName?.original} | ||||||
|                 onChange={(e) => |                 onChange={(e) => | ||||||
|                   setEditName({ |                   setEditName({ | ||||||
|                     original: editName.original ?? "", |                     original: editName.original ?? "", | ||||||
| @ -97,10 +105,7 @@ export default function ExportCard({ | |||||||
|                   size="sm" |                   size="sm" | ||||||
|                   variant="select" |                   variant="select" | ||||||
|                   disabled={(editName?.update?.length ?? 0) == 0} |                   disabled={(editName?.update?.length ?? 0) == 0} | ||||||
|                   onClick={() => { |                   onClick={() => submitRename()} | ||||||
|                     onRename(exportedRecording.id, editName.update); |  | ||||||
|                     setEditName(undefined); |  | ||||||
|                   }} |  | ||||||
|                 > |                 > | ||||||
|                   Save |                   Save | ||||||
|                 </Button> |                 </Button> | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ import { toast } from "sonner"; | |||||||
| import { useOverlayState } from "@/hooks/use-overlay-state"; | import { useOverlayState } from "@/hooks/use-overlay-state"; | ||||||
| import { usePersistence } from "@/hooks/use-persistence"; | import { usePersistence } from "@/hooks/use-persistence"; | ||||||
| import { cn } from "@/lib/utils"; | import { cn } from "@/lib/utils"; | ||||||
| import { ASPECT_VERTICAL_LAYOUT } from "@/types/record"; | import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; | ||||||
| 
 | 
 | ||||||
| // Android native hls does not seek correctly
 | // Android native hls does not seek correctly
 | ||||||
| const USE_NATIVE_HLS = !isAndroid; | const USE_NATIVE_HLS = !isAndroid; | ||||||
| @ -29,6 +29,7 @@ const unsupportedErrorCodes = [ | |||||||
| 
 | 
 | ||||||
| type HlsVideoPlayerProps = { | type HlsVideoPlayerProps = { | ||||||
|   videoRef: MutableRefObject<HTMLVideoElement | null>; |   videoRef: MutableRefObject<HTMLVideoElement | null>; | ||||||
|  |   containerRef?: React.MutableRefObject<HTMLDivElement | null>; | ||||||
|   visible: boolean; |   visible: boolean; | ||||||
|   currentSource: string; |   currentSource: string; | ||||||
|   hotKeys: boolean; |   hotKeys: boolean; | ||||||
| @ -40,10 +41,11 @@ type HlsVideoPlayerProps = { | |||||||
|   setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; |   setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; | ||||||
|   onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; |   onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; | ||||||
|   toggleFullscreen?: () => void; |   toggleFullscreen?: () => void; | ||||||
|   containerRef?: React.MutableRefObject<HTMLDivElement | null>; |   onError?: (error: RecordingPlayerError) => void; | ||||||
| }; | }; | ||||||
| export default function HlsVideoPlayer({ | export default function HlsVideoPlayer({ | ||||||
|   videoRef, |   videoRef, | ||||||
|  |   containerRef, | ||||||
|   visible, |   visible, | ||||||
|   currentSource, |   currentSource, | ||||||
|   hotKeys, |   hotKeys, | ||||||
| @ -55,7 +57,7 @@ export default function HlsVideoPlayer({ | |||||||
|   setFullResolution, |   setFullResolution, | ||||||
|   onUploadFrame, |   onUploadFrame, | ||||||
|   toggleFullscreen, |   toggleFullscreen, | ||||||
|   containerRef, |   onError, | ||||||
| }: HlsVideoPlayerProps) { | }: HlsVideoPlayerProps) { | ||||||
|   const { data: config } = useSWR<FrigateConfig>("config"); |   const { data: config } = useSWR<FrigateConfig>("config"); | ||||||
| 
 | 
 | ||||||
| @ -64,6 +66,7 @@ export default function HlsVideoPlayer({ | |||||||
|   const hlsRef = useRef<Hls>(); |   const hlsRef = useRef<Hls>(); | ||||||
|   const [useHlsCompat, setUseHlsCompat] = useState(false); |   const [useHlsCompat, setUseHlsCompat] = useState(false); | ||||||
|   const [loadedMetadata, setLoadedMetadata] = useState(false); |   const [loadedMetadata, setLoadedMetadata] = useState(false); | ||||||
|  |   const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>(); | ||||||
| 
 | 
 | ||||||
|   const handleLoadedMetadata = useCallback(() => { |   const handleLoadedMetadata = useCallback(() => { | ||||||
|     setLoadedMetadata(true); |     setLoadedMetadata(true); | ||||||
| @ -265,11 +268,42 @@ export default function HlsVideoPlayer({ | |||||||
|           onPlaying={onPlaying} |           onPlaying={onPlaying} | ||||||
|           onPause={() => { |           onPause={() => { | ||||||
|             setIsPlaying(false); |             setIsPlaying(false); | ||||||
|  |             clearTimeout(bufferTimeout); | ||||||
| 
 | 
 | ||||||
|             if (isMobile && mobileCtrlTimeout) { |             if (isMobile && mobileCtrlTimeout) { | ||||||
|               clearTimeout(mobileCtrlTimeout); |               clearTimeout(mobileCtrlTimeout); | ||||||
|             } |             } | ||||||
|           }} |           }} | ||||||
|  |           onWaiting={() => { | ||||||
|  |             if (onError != undefined) { | ||||||
|  |               if (videoRef.current?.paused) { | ||||||
|  |                 return; | ||||||
|  |               } | ||||||
|  | 
 | ||||||
|  |               setBufferTimeout( | ||||||
|  |                 setTimeout(() => { | ||||||
|  |                   if ( | ||||||
|  |                     document.visibilityState === "visible" && | ||||||
|  |                     videoRef.current | ||||||
|  |                   ) { | ||||||
|  |                     onError("stalled"); | ||||||
|  |                   } | ||||||
|  |                 }, 3000), | ||||||
|  |               ); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           onProgress={() => { | ||||||
|  |             if (onError != undefined) { | ||||||
|  |               if (videoRef.current?.paused) { | ||||||
|  |                 return; | ||||||
|  |               } | ||||||
|  | 
 | ||||||
|  |               if (bufferTimeout) { | ||||||
|  |                 clearTimeout(bufferTimeout); | ||||||
|  |                 setBufferTimeout(undefined); | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|           onTimeUpdate={() => |           onTimeUpdate={() => | ||||||
|             onTimeUpdate && videoRef.current |             onTimeUpdate && videoRef.current | ||||||
|               ? onTimeUpdate(videoRef.current.currentTime) |               ? onTimeUpdate(videoRef.current.currentTime) | ||||||
|  | |||||||
| @ -91,6 +91,7 @@ export default function DynamicVideoPlayer({ | |||||||
|   // initial state
 |   // initial state
 | ||||||
| 
 | 
 | ||||||
|   const [isLoading, setIsLoading] = useState(false); |   const [isLoading, setIsLoading] = useState(false); | ||||||
|  |   const [isBuffering, setIsBuffering] = useState(false); | ||||||
|   const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>(); |   const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>(); | ||||||
|   const [source, setSource] = useState( |   const [source, setSource] = useState( | ||||||
|     `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, |     `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, | ||||||
| @ -130,9 +131,13 @@ export default function DynamicVideoPlayer({ | |||||||
|         setIsLoading(false); |         setIsLoading(false); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       if (isBuffering) { | ||||||
|  |         setIsBuffering(false); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       onTimestampUpdate(controller.getProgress(time)); |       onTimestampUpdate(controller.getProgress(time)); | ||||||
|     }, |     }, | ||||||
|     [controller, onTimestampUpdate, isScrubbing, isLoading], |     [controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing], | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const onUploadFrameToPlus = useCallback( |   const onUploadFrameToPlus = useCallback( | ||||||
| @ -188,6 +193,7 @@ export default function DynamicVideoPlayer({ | |||||||
|     <> |     <> | ||||||
|       <HlsVideoPlayer |       <HlsVideoPlayer | ||||||
|         videoRef={playerRef} |         videoRef={playerRef} | ||||||
|  |         containerRef={containerRef} | ||||||
|         visible={!(isScrubbing || isLoading)} |         visible={!(isScrubbing || isLoading)} | ||||||
|         currentSource={source} |         currentSource={source} | ||||||
|         hotKeys={hotKeys} |         hotKeys={hotKeys} | ||||||
| @ -209,7 +215,11 @@ export default function DynamicVideoPlayer({ | |||||||
|         setFullResolution={setFullResolution} |         setFullResolution={setFullResolution} | ||||||
|         onUploadFrame={onUploadFrameToPlus} |         onUploadFrame={onUploadFrameToPlus} | ||||||
|         toggleFullscreen={toggleFullscreen} |         toggleFullscreen={toggleFullscreen} | ||||||
|         containerRef={containerRef} |         onError={(error) => { | ||||||
|  |           if (error == "stalled" && !isScrubbing) { | ||||||
|  |             setIsBuffering(true); | ||||||
|  |           } | ||||||
|  |         }} | ||||||
|       /> |       /> | ||||||
|       <PreviewPlayer |       <PreviewPlayer | ||||||
|         className={cn( |         className={cn( | ||||||
| @ -221,11 +231,11 @@ export default function DynamicVideoPlayer({ | |||||||
|         cameraPreviews={cameraPreviews} |         cameraPreviews={cameraPreviews} | ||||||
|         startTime={startTimestamp} |         startTime={startTimestamp} | ||||||
|         isScrubbing={isScrubbing} |         isScrubbing={isScrubbing} | ||||||
|         onControllerReady={(previewController) => { |         onControllerReady={(previewController) => | ||||||
|           setPreviewController(previewController); |           setPreviewController(previewController) | ||||||
|         }} |         } | ||||||
|       /> |       /> | ||||||
|       {!isScrubbing && isLoading && !noRecording && ( |       {!isScrubbing && (isLoading || isBuffering) && !noRecording && ( | ||||||
|         <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> |         <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> | ||||||
|       )} |       )} | ||||||
|       {!isScrubbing && noRecording && ( |       {!isScrubbing && noRecording && ( | ||||||
|  | |||||||
| @ -12,10 +12,12 @@ import { | |||||||
| import { Button } from "@/components/ui/button"; | import { Button } from "@/components/ui/button"; | ||||||
| import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; | import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; | ||||||
| import { Input } from "@/components/ui/input"; | import { Input } from "@/components/ui/input"; | ||||||
|  | import { Toaster } from "@/components/ui/sonner"; | ||||||
| import { DeleteClipType, Export } from "@/types/export"; | import { DeleteClipType, Export } from "@/types/export"; | ||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||||
| import { LuFolderX } from "react-icons/lu"; | import { LuFolderX } from "react-icons/lu"; | ||||||
|  | import { toast } from "sonner"; | ||||||
| import useSWR from "swr"; | import useSWR from "swr"; | ||||||
| 
 | 
 | ||||||
| function Exports() { | function Exports() { | ||||||
| @ -63,12 +65,26 @@ function Exports() { | |||||||
| 
 | 
 | ||||||
|   const onHandleRename = useCallback( |   const onHandleRename = useCallback( | ||||||
|     (id: string, update: string) => { |     (id: string, update: string) => { | ||||||
|       axios.patch(`export/${id}/${update}`).then((response) => { |       axios | ||||||
|         if (response.status == 200) { |         .patch(`export/${id}/${encodeURIComponent(update)}`) | ||||||
|           setDeleteClip(undefined); |         .then((response) => { | ||||||
|           mutate(); |           if (response.status == 200) { | ||||||
|         } |             setDeleteClip(undefined); | ||||||
|       }); |             mutate(); | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           if (error.response?.data?.message) { | ||||||
|  |             toast.error( | ||||||
|  |               `Failed to rename export: ${error.response.data.message}`, | ||||||
|  |               { position: "top-center" }, | ||||||
|  |             ); | ||||||
|  |           } else { | ||||||
|  |             toast.error(`Failed to rename export: ${error.message}`, { | ||||||
|  |               position: "top-center", | ||||||
|  |             }); | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|     }, |     }, | ||||||
|     [mutate], |     [mutate], | ||||||
|   ); |   ); | ||||||
| @ -79,6 +95,8 @@ function Exports() { | |||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> |     <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> | ||||||
|  |       <Toaster closeButton={true} /> | ||||||
|  | 
 | ||||||
|       <AlertDialog |       <AlertDialog | ||||||
|         open={deleteClip != undefined} |         open={deleteClip != undefined} | ||||||
|         onOpenChange={() => setDeleteClip(undefined)} |         onOpenChange={() => setDeleteClip(undefined)} | ||||||
|  | |||||||
| @ -39,5 +39,7 @@ export type RecordingStartingPoint = { | |||||||
|   severity: ReviewSeverity; |   severity: ReviewSeverity; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | export type RecordingPlayerError = "stalled" | "startup"; | ||||||
|  | 
 | ||||||
| export const ASPECT_VERTICAL_LAYOUT = 1.5; | export const ASPECT_VERTICAL_LAYOUT = 1.5; | ||||||
| export const ASPECT_WIDE_LAYOUT = 2; | export const ASPECT_WIDE_LAYOUT = 2; | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user