diff --git a/frigate/api/export.py b/frigate/api/export.py index 1f4e8e417..92313adde 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -13,7 +13,6 @@ from flask import ( request, ) from peewee import DoesNotExist -from werkzeug.utils import secure_filename from frigate.const import EXPORT_DIR 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 {} 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( jsonify({"success": False, "message": "File name is too long."}), 401, @@ -78,7 +77,7 @@ def export_recording(camera_name: str, start_time, end_time): exporter = RecordingExporter( current_app.frigate_config, camera_name, - secure_filename(name) if name else None, + friendly_name, int(start_time), int(end_time), ( diff --git a/web/package-lock.json b/web/package-lock.json index 75100ba90..b49d6ad83 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -59,7 +59,7 @@ "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", - "react-zoom-pan-pinch": "^3.6.1", + "react-zoom-pan-pinch": "3.4.4", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.5.0", @@ -6841,9 +6841,9 @@ } }, "node_modules/react-zoom-pan-pinch": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.6.1.tgz", - "integrity": "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz", + "integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==", "license": "MIT", "engines": { "node": ">=8", diff --git a/web/package.json b/web/package.json index 704507c82..328e995b7 100644 --- a/web/package.json +++ b/web/package.json @@ -65,7 +65,7 @@ "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", - "react-zoom-pan-pinch": "^3.6.1", + "react-zoom-pan-pinch": "3.4.4", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.5.0", diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 95357956a..a2c92b5fb 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,7 +1,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { isDesktop } from "react-device-detect"; import { FaDownload, FaPlay } from "react-icons/fa"; import Chip from "../indicators/Chip"; @@ -47,6 +47,15 @@ export default function ExportCard({ update: string; }>(); + const submitRename = useCallback(() => { + if (editName == undefined) { + return; + } + + onRename(exportedRecording.id, editName.update); + setEditName(undefined); + }, [editName, exportedRecording, onRename, setEditName]); + useKeyboardListener( editName != undefined ? ["Enter"] : [], (key, modifiers) => { @@ -57,8 +66,7 @@ export default function ExportCard({ editName && editName.update.length > 0 ) { - onRename(exportedRecording.id, editName.update); - setEditName(undefined); + submitRename(); } }, ); @@ -84,7 +92,7 @@ export default function ExportCard({ className="mt-3" type="search" placeholder={editName?.original} - value={editName?.update} + value={editName?.update || editName?.original} onChange={(e) => setEditName({ original: editName.original ?? "", @@ -97,10 +105,7 @@ export default function ExportCard({ size="sm" variant="select" disabled={(editName?.update?.length ?? 0) == 0} - onClick={() => { - onRename(exportedRecording.id, editName.update); - setEditName(undefined); - }} + onClick={() => submitRename()} > Save diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index d66ed4b53..5f499ade7 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -17,7 +17,7 @@ import { toast } from "sonner"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { usePersistence } from "@/hooks/use-persistence"; 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 const USE_NATIVE_HLS = !isAndroid; @@ -29,6 +29,7 @@ const unsupportedErrorCodes = [ type HlsVideoPlayerProps = { videoRef: MutableRefObject; + containerRef?: React.MutableRefObject; visible: boolean; currentSource: string; hotKeys: boolean; @@ -40,10 +41,11 @@ type HlsVideoPlayerProps = { setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; - containerRef?: React.MutableRefObject; + onError?: (error: RecordingPlayerError) => void; }; export default function HlsVideoPlayer({ videoRef, + containerRef, visible, currentSource, hotKeys, @@ -55,7 +57,7 @@ export default function HlsVideoPlayer({ setFullResolution, onUploadFrame, toggleFullscreen, - containerRef, + onError, }: HlsVideoPlayerProps) { const { data: config } = useSWR("config"); @@ -64,6 +66,7 @@ export default function HlsVideoPlayer({ const hlsRef = useRef(); const [useHlsCompat, setUseHlsCompat] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false); + const [bufferTimeout, setBufferTimeout] = useState(); const handleLoadedMetadata = useCallback(() => { setLoadedMetadata(true); @@ -265,11 +268,42 @@ export default function HlsVideoPlayer({ onPlaying={onPlaying} onPause={() => { setIsPlaying(false); + clearTimeout(bufferTimeout); if (isMobile && 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 && videoRef.current ? onTimeUpdate(videoRef.current.currentTime) diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index 3f82cd352..9e2d9a353 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -91,6 +91,7 @@ export default function DynamicVideoPlayer({ // initial state const [isLoading, setIsLoading] = useState(false); + const [isBuffering, setIsBuffering] = useState(false); const [loadingTimeout, setLoadingTimeout] = useState(); const [source, setSource] = useState( `${apiHost}vod/${camera}/start/${timeRange.after}/end/${timeRange.before}/master.m3u8`, @@ -130,9 +131,13 @@ export default function DynamicVideoPlayer({ setIsLoading(false); } + if (isBuffering) { + setIsBuffering(false); + } + onTimestampUpdate(controller.getProgress(time)); }, - [controller, onTimestampUpdate, isScrubbing, isLoading], + [controller, onTimestampUpdate, isBuffering, isLoading, isScrubbing], ); const onUploadFrameToPlus = useCallback( @@ -188,6 +193,7 @@ export default function DynamicVideoPlayer({ <> { + if (error == "stalled" && !isScrubbing) { + setIsBuffering(true); + } + }} /> { - setPreviewController(previewController); - }} + onControllerReady={(previewController) => + setPreviewController(previewController) + } /> - {!isScrubbing && isLoading && !noRecording && ( + {!isScrubbing && (isLoading || isBuffering) && !noRecording && ( )} {!isScrubbing && noRecording && ( diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 9cf9f4dcc..411a53e6a 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -12,10 +12,12 @@ import { import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Toaster } from "@/components/ui/sonner"; import { DeleteClipType, Export } from "@/types/export"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import { LuFolderX } from "react-icons/lu"; +import { toast } from "sonner"; import useSWR from "swr"; function Exports() { @@ -63,12 +65,26 @@ function Exports() { const onHandleRename = useCallback( (id: string, update: string) => { - axios.patch(`export/${id}/${update}`).then((response) => { - if (response.status == 200) { - setDeleteClip(undefined); - mutate(); - } - }); + axios + .patch(`export/${id}/${encodeURIComponent(update)}`) + .then((response) => { + 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], ); @@ -79,6 +95,8 @@ function Exports() { return (
+ + setDeleteClip(undefined)} diff --git a/web/src/types/record.ts b/web/src/types/record.ts index d3fcfce94..a93029376 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -39,5 +39,7 @@ export type RecordingStartingPoint = { severity: ReviewSeverity; }; +export type RecordingPlayerError = "stalled" | "startup"; + export const ASPECT_VERTICAL_LAYOUT = 1.5; export const ASPECT_WIDE_LAYOUT = 2;