mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Add ability to play and delete exports from webUI (#7882)
* add ability to playback exports on exports screen * Add ability to delete exports from exports screen * Fix large dialog * Formatting
This commit is contained in:
parent
9a1c8b2cc4
commit
e5664826b1
@ -34,6 +34,7 @@ from frigate.const import (
|
|||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
|
EXPORT_DIR,
|
||||||
MAX_SEGMENT_DURATION,
|
MAX_SEGMENT_DURATION,
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
)
|
)
|
||||||
@ -1666,6 +1667,20 @@ def export_recording(camera_name: str, start_time, end_time):
|
|||||||
return "Starting export of recording", 200
|
return "Starting export of recording", 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/export/<file_name>", methods=["DELETE"])
|
||||||
|
def export_delete(file_name: str):
|
||||||
|
file = os.path.join(EXPORT_DIR, file_name)
|
||||||
|
|
||||||
|
if not os.path.exists(file):
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": f"{file_name} not found."}),
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.unlink(file)
|
||||||
|
return "Successfully deleted file", 200
|
||||||
|
|
||||||
|
|
||||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||||
while True:
|
while True:
|
||||||
# max out at specified FPS
|
# max out at specified FPS
|
||||||
|
35
web/src/components/DialogLarge.jsx
Normal file
35
web/src/components/DialogLarge.jsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function LargeDialog({ children, portalRootID = 'dialogs' }) {
|
||||||
|
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setShow(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
data-testid="scrim"
|
||||||
|
key="scrim"
|
||||||
|
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="modal"
|
||||||
|
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 w-4/5 max-w-7xl text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||||
|
show ? 'scale-100 opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||||
|
}
|
@ -1,16 +1,22 @@
|
|||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import useSWR from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { baseUrl } from '../api/baseUrl';
|
import { baseUrl } from '../api/baseUrl';
|
||||||
import { Fragment } from 'preact';
|
import { Fragment } from 'preact';
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
|
import { Play } from '../icons/Play';
|
||||||
|
import { Delete } from '../icons/Delete';
|
||||||
|
import LargeDialog from '../components/DialogLarge';
|
||||||
|
import VideoPlayer from '../components/VideoPlayer';
|
||||||
|
import Dialog from '../components/Dialog';
|
||||||
|
|
||||||
export default function Export() {
|
export default function Export() {
|
||||||
const { data: config } = useSWR('config');
|
const { data: config } = useSWR('config');
|
||||||
const { data: exports } = useSWR('exports/', (url) => axios({ baseURL: baseUrl, url }).then((res) => res.data));
|
const { data: exports } = useSWR('exports/', (url) => axios({ baseURL: baseUrl, url }).then((res) => res.data));
|
||||||
|
|
||||||
|
// Export States
|
||||||
const [camera, setCamera] = useState('select');
|
const [camera, setCamera] = useState('select');
|
||||||
const [playback, setPlayback] = useState('select');
|
const [playback, setPlayback] = useState('select');
|
||||||
const [message, setMessage] = useState({ text: '', error: false });
|
const [message, setMessage] = useState({ text: '', error: false });
|
||||||
@ -26,6 +32,11 @@ export default function Export() {
|
|||||||
const [endDate, setEndDate] = useState(localISODate);
|
const [endDate, setEndDate] = useState(localISODate);
|
||||||
const [endTime, setEndTime] = useState('23:59');
|
const [endTime, setEndTime] = useState('23:59');
|
||||||
|
|
||||||
|
// Export States
|
||||||
|
|
||||||
|
const [selectedClip, setSelectedClip] = useState();
|
||||||
|
const [deleteClip, setDeleteClip] = useState();
|
||||||
|
|
||||||
const onHandleExport = () => {
|
const onHandleExport = () => {
|
||||||
if (camera == 'select') {
|
if (camera == 'select') {
|
||||||
setMessage({ text: 'A camera needs to be selected.', error: true });
|
setMessage({ text: 'A camera needs to be selected.', error: true });
|
||||||
@ -66,6 +77,15 @@ export default function Export() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onHandleDelete = (clip) => {
|
||||||
|
axios.delete(`export/${clip}`).then((response) => {
|
||||||
|
if (response.status == 200) {
|
||||||
|
setDeleteClip();
|
||||||
|
mutate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-2 px-4 w-full">
|
<div className="space-y-4 p-2 px-4 w-full">
|
||||||
<Heading>Export</Heading>
|
<Heading>Export</Heading>
|
||||||
@ -74,6 +94,55 @@ export default function Export() {
|
|||||||
<div className={`max-h-20 ${message.error ? 'text-red-500' : 'text-green-500'}`}>{message.text}</div>
|
<div className={`max-h-20 ${message.error ? 'text-red-500' : 'text-green-500'}`}>{message.text}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedClip && (
|
||||||
|
<LargeDialog>
|
||||||
|
<div>
|
||||||
|
<Heading className="p-2">Playback</Heading>
|
||||||
|
<VideoPlayer
|
||||||
|
options={{
|
||||||
|
preload: 'auto',
|
||||||
|
autoplay: true,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: `${baseUrl}exports/${selectedClip}`,
|
||||||
|
type: 'video/mp4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
seekOptions={{ forward: 10, backward: 5 }}
|
||||||
|
onReady={(player) => {
|
||||||
|
this.player = player;
|
||||||
|
}}
|
||||||
|
onDispose={() => {
|
||||||
|
this.player = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
<Button className="ml-2" onClick={() => setSelectedClip('')} type="text">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</LargeDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteClip && (
|
||||||
|
<Dialog>
|
||||||
|
<div className="p-4">
|
||||||
|
<Heading size="lg">Delete Export?</Heading>
|
||||||
|
<p className="py-4 mb-2">Confirm deletion of {deleteClip}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
<Button className="ml-2" onClick={() => setDeleteClip('')} type="text">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button className="ml-2" color="red" onClick={() => onHandleDelete(deleteClip)} type="text">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="xl:flex justify-between">
|
<div className="xl:flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
@ -144,7 +213,11 @@ export default function Export() {
|
|||||||
{exports && (
|
{exports && (
|
||||||
<div className="p-4 bg-gray-800 xl:w-1/2">
|
<div className="p-4 bg-gray-800 xl:w-1/2">
|
||||||
<Heading size="md">Exports</Heading>
|
<Heading size="md">Exports</Heading>
|
||||||
<Exports exports={exports} />
|
<Exports
|
||||||
|
exports={exports}
|
||||||
|
onSetClip={(clip) => setSelectedClip(clip)}
|
||||||
|
onDeleteClip={(clip) => setDeleteClip(clip)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -152,7 +225,7 @@ export default function Export() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Exports({ exports }) {
|
function Exports({ exports, onSetClip, onDeleteClip }) {
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{exports.map((item) => (
|
{exports.map((item) => (
|
||||||
@ -166,9 +239,19 @@ function Exports({ exports }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-start items-center">
|
<div className="flex justify-start items-center">
|
||||||
<a className="text-blue-500 hover:underline" href={`${baseUrl}exports/${item.name}`} download>
|
<Button type="iconOnly" onClick={() => onSetClip(item.name)}>
|
||||||
|
<Play className="h-6 w-6 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
className="text-blue-500 hover:underline overflow-hidden"
|
||||||
|
href={`${baseUrl}exports/${item.name}`}
|
||||||
|
download
|
||||||
|
>
|
||||||
{item.name.substring(0, item.name.length - 4)}
|
{item.name.substring(0, item.name.length - 4)}
|
||||||
</a>
|
</a>
|
||||||
|
<Button className="ml-auto" type="iconOnly" onClick={() => onDeleteClip(item.name)}>
|
||||||
|
<Delete className="h-6 w-6" stroke="#f87171" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user