mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +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