* 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:
Nicolas Mowen 2024-07-17 07:39:37 -06:00 committed by GitHub
parent 78c15f3020
commit c56e7e7c6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 100 additions and 32 deletions

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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)

View File

@ -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 && (

View File

@ -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,11 +65,25 @@ function Exports() {
const onHandleRename = useCallback( const onHandleRename = useCallback(
(id: string, update: string) => { (id: string, update: string) => {
axios.patch(`export/${id}/${update}`).then((response) => { axios
.patch(`export/${id}/${encodeURIComponent(update)}`)
.then((response) => {
if (response.status == 200) { if (response.status == 200) {
setDeleteClip(undefined); setDeleteClip(undefined);
mutate(); 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)}

View File

@ -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;