* 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,
)
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),
(

8
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -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<HTMLVideoElement | null>;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
visible: boolean;
currentSource: string;
hotKeys: boolean;
@ -40,10 +41,11 @@ type HlsVideoPlayerProps = {
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
toggleFullscreen?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
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<FrigateConfig>("config");
@ -64,6 +66,7 @@ export default function HlsVideoPlayer({
const hlsRef = useRef<Hls>();
const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
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)

View File

@ -91,6 +91,7 @@ export default function DynamicVideoPlayer({
// initial state
const [isLoading, setIsLoading] = useState(false);
const [isBuffering, setIsBuffering] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<NodeJS.Timeout>();
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({
<>
<HlsVideoPlayer
videoRef={playerRef}
containerRef={containerRef}
visible={!(isScrubbing || isLoading)}
currentSource={source}
hotKeys={hotKeys}
@ -209,7 +215,11 @@ export default function DynamicVideoPlayer({
setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus}
toggleFullscreen={toggleFullscreen}
containerRef={containerRef}
onError={(error) => {
if (error == "stalled" && !isScrubbing) {
setIsBuffering(true);
}
}}
/>
<PreviewPlayer
className={cn(
@ -221,11 +231,11 @@ export default function DynamicVideoPlayer({
cameraPreviews={cameraPreviews}
startTime={startTimestamp}
isScrubbing={isScrubbing}
onControllerReady={(previewController) => {
setPreviewController(previewController);
}}
onControllerReady={(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" />
)}
{!isScrubbing && noRecording && (

View File

@ -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,11 +65,25 @@ function Exports() {
const onHandleRename = useCallback(
(id: string, update: string) => {
axios.patch(`export/${id}/${update}`).then((response) => {
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 (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} />
<AlertDialog
open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)}

View File

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