mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Preview improvements (#10384)
* Write preview frames as webp instead of jpg and ensure webp are cached in nginx * Support preview player that shows current hour images * Update to get preview player working * Use timestamp based recordings check instead of calendar * Start motion review from current time * Adjust layout * Use preview players for previews * remove vite * Cleanup * Fix up the layout
This commit is contained in:
parent
fa22f01f39
commit
1c5d6765a1
@ -210,7 +210,7 @@ http {
|
|||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* /api/.*\.(jpg|jpeg|png)$ {
|
location ~* /api/.*\.(jpg|jpeg|png|webp)$ {
|
||||||
rewrite ^/api/(.*)$ $1 break;
|
rewrite ^/api/(.*)$ $1 break;
|
||||||
proxy_pass http://frigate_api;
|
proxy_pass http://frigate_api;
|
||||||
include proxy.conf;
|
include proxy.conf;
|
||||||
|
@ -30,6 +30,7 @@ from frigate.const import (
|
|||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
EXPORT_DIR,
|
EXPORT_DIR,
|
||||||
MAX_SEGMENT_DURATION,
|
MAX_SEGMENT_DURATION,
|
||||||
|
PREVIEW_FRAME_TYPE,
|
||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
@ -1173,8 +1174,8 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
|
|||||||
# need to generate from existing images
|
# need to generate from existing images
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
file_start = f"preview_{camera_name}"
|
file_start = f"preview_{camera_name}"
|
||||||
start_file = f"{file_start}-{start_ts}.jpg"
|
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}-{end_ts}.jpg"
|
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
selected_previews = []
|
selected_previews = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in sorted(os.listdir(preview_dir)):
|
||||||
@ -1258,8 +1259,9 @@ def review_preview(id: str):
|
|||||||
|
|
||||||
|
|
||||||
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")
|
@MediaBp.route("/preview/<file_name>/thumbnail.jpg")
|
||||||
|
@MediaBp.route("/preview/<file_name>/thumbnail.webp")
|
||||||
def preview_thumbnail(file_name: str):
|
def preview_thumbnail(file_name: str):
|
||||||
"""Get a thumbnail from the cached preview jpgs."""
|
"""Get a thumbnail from the cached preview frames."""
|
||||||
safe_file_name_current = secure_filename(file_name)
|
safe_file_name_current = secure_filename(file_name)
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from flask import (
|
|||||||
make_response,
|
make_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
from frigate.const import CACHE_DIR
|
from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||||
from frigate.models import Previews
|
from frigate.models import Previews
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -97,8 +97,8 @@ def get_preview_frames_from_cache(camera_name: str, start_ts, end_ts):
|
|||||||
"""Get list of cached preview frames"""
|
"""Get list of cached preview frames"""
|
||||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
file_start = f"preview_{camera_name}"
|
file_start = f"preview_{camera_name}"
|
||||||
start_file = f"{file_start}-{start_ts}.jpg"
|
start_file = f"{file_start}-{start_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
end_file = f"{file_start}-{end_ts}.jpg"
|
end_file = f"{file_start}-{end_ts}.{PREVIEW_FRAME_TYPE}"
|
||||||
selected_previews = []
|
selected_previews = []
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in sorted(os.listdir(preview_dir)):
|
||||||
|
@ -57,6 +57,10 @@ DRIVER_AMD = "radeonsi"
|
|||||||
DRIVER_INTEL_i965 = "i965"
|
DRIVER_INTEL_i965 = "i965"
|
||||||
DRIVER_INTEL_iHD = "iHD"
|
DRIVER_INTEL_iHD = "iHD"
|
||||||
|
|
||||||
|
# Preview Values
|
||||||
|
|
||||||
|
PREVIEW_FRAME_TYPE = "webp"
|
||||||
|
|
||||||
# Record Values
|
# Record Values
|
||||||
|
|
||||||
CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z"
|
CACHE_SEGMENT_FORMAT = "%Y%m%d%H%M%S%z"
|
||||||
|
@ -12,7 +12,7 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import CameraConfig, RecordQualityEnum
|
from frigate.config import CameraConfig, RecordQualityEnum
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW
|
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE
|
||||||
from frigate.ffmpeg_presets import (
|
from frigate.ffmpeg_presets import (
|
||||||
FPS_VFR_PARAM,
|
FPS_VFR_PARAM,
|
||||||
EncodeTypeEnum,
|
EncodeTypeEnum,
|
||||||
@ -42,12 +42,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
|
|||||||
"""Get the image name in cache."""
|
"""Get the image name in cache."""
|
||||||
return os.path.join(
|
return os.path.join(
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.jpg",
|
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FFMpegConverter(threading.Thread):
|
class FFMpegConverter(threading.Thread):
|
||||||
"""Convert a list of jpg frames into a vfr mp4."""
|
"""Convert a list of still frames into a vfr mp4."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -176,7 +176,7 @@ class PreviewRecorder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_start = f"preview_{config.name}"
|
file_start = f"preview_{config.name}"
|
||||||
start_file = f"{file_start}-{start_ts}.jpg"
|
start_file = f"{file_start}-{start_ts}.webp"
|
||||||
|
|
||||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
||||||
if not file.startswith(file_start):
|
if not file.startswith(file_start):
|
||||||
@ -186,7 +186,7 @@ class PreviewRecorder:
|
|||||||
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ts = float(file.split("-")[1][:-4])
|
ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)])
|
||||||
|
|
||||||
if self.start_time == 0:
|
if self.start_time == 0:
|
||||||
self.start_time = ts
|
self.start_time = ts
|
||||||
@ -242,12 +242,11 @@ class PreviewRecorder:
|
|||||||
small_frame,
|
small_frame,
|
||||||
cv2.COLOR_YUV2BGR_I420,
|
cv2.COLOR_YUV2BGR_I420,
|
||||||
)
|
)
|
||||||
_, jpg = cv2.imencode(".jpg", small_frame)
|
cv2.imwrite(
|
||||||
with open(
|
|
||||||
get_cache_image_name(self.config.name, frame_time),
|
get_cache_image_name(self.config.name, frame_time),
|
||||||
"wb",
|
small_frame,
|
||||||
) as j:
|
[int(cv2.IMWRITE_WEBP_QUALITY), 80],
|
||||||
j.write(jpg.tobytes())
|
)
|
||||||
|
|
||||||
def write_data(
|
def write_data(
|
||||||
self,
|
self,
|
||||||
|
@ -9,9 +9,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|||||||
import { Recording } from "@/types/record";
|
import { Recording } from "@/types/record";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { DynamicPlayback } from "@/types/playback";
|
import { DynamicPlayback } from "@/types/playback";
|
||||||
import PreviewVideoPlayer, {
|
import PreviewPlayer, { PreviewController } from "./PreviewPlayer";
|
||||||
PreviewVideoController,
|
|
||||||
} from "./PreviewVideoPlayer";
|
|
||||||
|
|
||||||
type PlayerMode = "playback" | "scrubbing";
|
type PlayerMode = "playback" | "scrubbing";
|
||||||
|
|
||||||
@ -40,11 +38,6 @@ export default function DynamicVideoPlayer({
|
|||||||
}: DynamicVideoPlayerProps) {
|
}: DynamicVideoPlayerProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const timezone = useMemo(
|
|
||||||
() =>
|
|
||||||
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
[config],
|
|
||||||
);
|
|
||||||
|
|
||||||
// playback behavior
|
// playback behavior
|
||||||
const wideVideo = useMemo(() => {
|
const wideVideo = useMemo(() => {
|
||||||
@ -63,7 +56,7 @@ export default function DynamicVideoPlayer({
|
|||||||
|
|
||||||
const [playerRef, setPlayerRef] = useState<Player | null>(null);
|
const [playerRef, setPlayerRef] = useState<Player | null>(null);
|
||||||
const [previewController, setPreviewController] =
|
const [previewController, setPreviewController] =
|
||||||
useState<PreviewVideoController | null>(null);
|
useState<PreviewController | null>(null);
|
||||||
const [isScrubbing, setIsScrubbing] = useState(previewOnly);
|
const [isScrubbing, setIsScrubbing] = useState(previewOnly);
|
||||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@ -154,14 +147,8 @@ export default function DynamicVideoPlayer({
|
|||||||
// initial state
|
// initial state
|
||||||
|
|
||||||
const initialPlaybackSource = useMemo(() => {
|
const initialPlaybackSource = useMemo(() => {
|
||||||
const date = new Date(timeRange.start * 1000);
|
|
||||||
return {
|
return {
|
||||||
src: `${apiHost}vod/${date.getFullYear()}-${
|
src: `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
||||||
date.getMonth() + 1
|
|
||||||
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
|
|
||||||
"/",
|
|
||||||
",",
|
|
||||||
)}/master.m3u8`,
|
|
||||||
type: "application/vnd.apple.mpegurl",
|
type: "application/vnd.apple.mpegurl",
|
||||||
};
|
};
|
||||||
// we only want to calculate this once
|
// we only want to calculate this once
|
||||||
@ -224,13 +211,7 @@ export default function DynamicVideoPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(timeRange.start * 1000);
|
const playbackUri = `${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`;
|
||||||
const playbackUri = `${apiHost}vod/${date.getFullYear()}-${
|
|
||||||
date.getMonth() + 1
|
|
||||||
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
|
|
||||||
"/",
|
|
||||||
",",
|
|
||||||
)}/master.m3u8`;
|
|
||||||
|
|
||||||
controller.newPlayback({
|
controller.newPlayback({
|
||||||
recordings: recordings ?? [],
|
recordings: recordings ?? [],
|
||||||
@ -280,7 +261,7 @@ export default function DynamicVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
</div>
|
</div>
|
||||||
<PreviewVideoPlayer
|
<PreviewPlayer
|
||||||
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
||||||
camera={camera}
|
camera={camera}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
@ -298,7 +279,7 @@ export class DynamicVideoController {
|
|||||||
// main state
|
// main state
|
||||||
public camera = "";
|
public camera = "";
|
||||||
private playerController: Player;
|
private playerController: Player;
|
||||||
private previewController: PreviewVideoController;
|
private previewController: PreviewController;
|
||||||
private setScrubbing: (isScrubbing: boolean) => void;
|
private setScrubbing: (isScrubbing: boolean) => void;
|
||||||
private setFocusedItem: (timeline: Timeline) => void;
|
private setFocusedItem: (timeline: Timeline) => void;
|
||||||
private playerMode: PlayerMode = "playback";
|
private playerMode: PlayerMode = "playback";
|
||||||
@ -315,7 +296,7 @@ export class DynamicVideoController {
|
|||||||
constructor(
|
constructor(
|
||||||
camera: string,
|
camera: string,
|
||||||
playerController: Player,
|
playerController: Player,
|
||||||
previewController: PreviewVideoController,
|
previewController: PreviewController,
|
||||||
annotationOffset: number,
|
annotationOffset: number,
|
||||||
defaultMode: PlayerMode,
|
defaultMode: PlayerMode,
|
||||||
setScrubbing: (isScrubbing: boolean) => void,
|
setScrubbing: (isScrubbing: boolean) => void,
|
||||||
|
458
web/src/components/player/PreviewPlayer.tsx
Normal file
458
web/src/components/player/PreviewPlayer.tsx
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { Preview } from "@/types/preview";
|
||||||
|
import { PreviewPlayback } from "@/types/playback";
|
||||||
|
import { isCurrentHour } from "@/utils/dateUtil";
|
||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
|
||||||
|
type PreviewPlayerProps = {
|
||||||
|
className?: string;
|
||||||
|
camera: string;
|
||||||
|
timeRange: { start: number; end: number };
|
||||||
|
cameraPreviews: Preview[];
|
||||||
|
startTime?: number;
|
||||||
|
onControllerReady: (controller: PreviewController) => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
export default function PreviewPlayer({
|
||||||
|
className,
|
||||||
|
camera,
|
||||||
|
timeRange,
|
||||||
|
cameraPreviews,
|
||||||
|
startTime,
|
||||||
|
onControllerReady,
|
||||||
|
onClick,
|
||||||
|
}: PreviewPlayerProps) {
|
||||||
|
if (isCurrentHour(timeRange.end)) {
|
||||||
|
return (
|
||||||
|
<PreviewFramesPlayer
|
||||||
|
className={className}
|
||||||
|
camera={camera}
|
||||||
|
timeRange={timeRange}
|
||||||
|
startTime={startTime}
|
||||||
|
onControllerReady={onControllerReady}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewVideoPlayer
|
||||||
|
className={className}
|
||||||
|
camera={camera}
|
||||||
|
timeRange={timeRange}
|
||||||
|
cameraPreviews={cameraPreviews}
|
||||||
|
startTime={startTime}
|
||||||
|
onControllerReady={onControllerReady}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class PreviewController {
|
||||||
|
public camera = "";
|
||||||
|
|
||||||
|
constructor(camera: string) {
|
||||||
|
this.camera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract scrubToTimestamp(time: number): boolean;
|
||||||
|
|
||||||
|
abstract finishedSeeking(): void;
|
||||||
|
|
||||||
|
abstract setNewPreviewStartTime(time: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewVideoPlayerProps = {
|
||||||
|
className?: string;
|
||||||
|
camera: string;
|
||||||
|
timeRange: { start: number; end: number };
|
||||||
|
cameraPreviews: Preview[];
|
||||||
|
startTime?: number;
|
||||||
|
onControllerReady: (controller: PreviewVideoController) => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
function PreviewVideoPlayer({
|
||||||
|
className,
|
||||||
|
camera,
|
||||||
|
timeRange,
|
||||||
|
cameraPreviews,
|
||||||
|
startTime,
|
||||||
|
onControllerReady,
|
||||||
|
onClick,
|
||||||
|
}: PreviewVideoPlayerProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// controlling playback
|
||||||
|
|
||||||
|
const previewRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const controller = useMemo(() => {
|
||||||
|
if (!config || !previewRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PreviewVideoController(camera, previewRef);
|
||||||
|
// we only care when preview is ready
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [camera, config, previewRef.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller) {
|
||||||
|
onControllerReady(controller);
|
||||||
|
}
|
||||||
|
// we only want to fire once when players are ready
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
// initial state
|
||||||
|
|
||||||
|
const initialPreview = useMemo(() => {
|
||||||
|
return cameraPreviews.find(
|
||||||
|
(preview) =>
|
||||||
|
preview.camera == camera &&
|
||||||
|
Math.round(preview.start) >= timeRange.start &&
|
||||||
|
Math.floor(preview.end) <= timeRange.end,
|
||||||
|
);
|
||||||
|
|
||||||
|
// we only want to calculate this once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [currentPreview, setCurrentPreview] = useState(initialPreview);
|
||||||
|
|
||||||
|
const onPreviewSeeked = useCallback(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.finishedSeeking();
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = cameraPreviews.find(
|
||||||
|
(preview) =>
|
||||||
|
preview.camera == camera &&
|
||||||
|
Math.round(preview.start) >= timeRange.start &&
|
||||||
|
Math.floor(preview.end) <= timeRange.end,
|
||||||
|
);
|
||||||
|
setCurrentPreview(preview);
|
||||||
|
|
||||||
|
controller.newPlayback({
|
||||||
|
preview,
|
||||||
|
timeRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// we only want this to change when recordings update
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [controller, timeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPreview || !previewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRef.current.load();
|
||||||
|
}, [currentPreview, previewRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={previewRef}
|
||||||
|
className={`size-full rounded-2xl bg-black`}
|
||||||
|
preload="auto"
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
disableRemotePlayback
|
||||||
|
onSeeked={onPreviewSeeked}
|
||||||
|
onLoadedData={() => {
|
||||||
|
if (controller) {
|
||||||
|
controller.previewReady();
|
||||||
|
} else {
|
||||||
|
previewRef.current?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewRef.current && startTime && currentPreview) {
|
||||||
|
previewRef.current.currentTime = startTime - currentPreview.start;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPreview != undefined && (
|
||||||
|
<source src={currentPreview.src} type={currentPreview.type} />
|
||||||
|
)}
|
||||||
|
</video>
|
||||||
|
{cameraPreviews && !currentPreview && (
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
||||||
|
No Preview Found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreviewVideoController extends PreviewController {
|
||||||
|
// main state
|
||||||
|
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
||||||
|
private timeRange: { start: number; end: number } | undefined = undefined;
|
||||||
|
|
||||||
|
// preview
|
||||||
|
private preview: Preview | undefined = undefined;
|
||||||
|
private timeToSeek: number | undefined = undefined;
|
||||||
|
private seeking = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
camera: string,
|
||||||
|
previewRef: MutableRefObject<HTMLVideoElement | null>,
|
||||||
|
) {
|
||||||
|
super(camera);
|
||||||
|
this.previewRef = previewRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
newPlayback(newPlayback: PreviewPlayback) {
|
||||||
|
this.preview = newPlayback.preview;
|
||||||
|
this.seeking = false;
|
||||||
|
|
||||||
|
this.timeRange = newPlayback.timeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
override scrubToTimestamp(time: number): boolean {
|
||||||
|
if (!this.preview || !this.timeRange) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time < this.preview.start || time > this.preview.end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.seeking) {
|
||||||
|
this.timeToSeek = time;
|
||||||
|
} else {
|
||||||
|
if (this.previewRef.current) {
|
||||||
|
this.previewRef.current.currentTime = Math.max(
|
||||||
|
0,
|
||||||
|
time - this.preview.start,
|
||||||
|
);
|
||||||
|
this.seeking = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override finishedSeeking() {
|
||||||
|
if (!this.previewRef.current || !this.preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.timeToSeek &&
|
||||||
|
this.timeToSeek != this.previewRef.current?.currentTime
|
||||||
|
) {
|
||||||
|
this.previewRef.current.currentTime =
|
||||||
|
this.timeToSeek - this.preview.start;
|
||||||
|
} else {
|
||||||
|
this.seeking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override setNewPreviewStartTime(time: number) {
|
||||||
|
this.timeToSeek = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewReady() {
|
||||||
|
this.seeking = false;
|
||||||
|
this.previewRef.current?.pause();
|
||||||
|
|
||||||
|
if (this.timeToSeek) {
|
||||||
|
this.finishedSeeking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewFramesPlayerProps = {
|
||||||
|
className?: string;
|
||||||
|
camera: string;
|
||||||
|
timeRange: { start: number; end: number };
|
||||||
|
startTime?: number;
|
||||||
|
onControllerReady: (controller: PreviewController) => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
function PreviewFramesPlayer({
|
||||||
|
className,
|
||||||
|
camera,
|
||||||
|
timeRange,
|
||||||
|
startTime,
|
||||||
|
onControllerReady,
|
||||||
|
onClick,
|
||||||
|
}: PreviewFramesPlayerProps) {
|
||||||
|
// frames data
|
||||||
|
|
||||||
|
const { data: previewFrames } = useSWR<string[]>(
|
||||||
|
`preview/${camera}/start/${Math.floor(timeRange.start)}/end/${Math.ceil(
|
||||||
|
timeRange.end,
|
||||||
|
)}/frames`,
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
const frameTimes = useMemo(() => {
|
||||||
|
if (!previewFrames) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewFrames.map((frame) =>
|
||||||
|
parseFloat(frame.split("-")[1].slice(undefined, -5)),
|
||||||
|
);
|
||||||
|
}, [previewFrames]);
|
||||||
|
|
||||||
|
// controlling frames
|
||||||
|
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const controller = useMemo(() => {
|
||||||
|
if (!frameTimes || !imgRef.current) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PreviewFramesController(camera, imgRef, frameTimes);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [imgRef, frameTimes, imgRef.current]);
|
||||||
|
|
||||||
|
// initial state
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller) {
|
||||||
|
onControllerReady(controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we only want to fire once when players are ready
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
const onImageLoaded = useCallback(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.finishedSeeking();
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime) {
|
||||||
|
controller.scrubToTimestamp(frameTimes?.at(-1) ?? timeRange.start);
|
||||||
|
} else {
|
||||||
|
controller.scrubToTimestamp(startTime);
|
||||||
|
}
|
||||||
|
// we only want to calculate this once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [controller]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
className={`size-full object-contain rounded-2xl bg-black`}
|
||||||
|
onLoad={onImageLoaded}
|
||||||
|
/>
|
||||||
|
{previewFrames?.length === 0 && (
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
||||||
|
No Preview Found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PreviewFramesController extends PreviewController {
|
||||||
|
imgController: MutableRefObject<HTMLImageElement | null>;
|
||||||
|
frameTimes: number[];
|
||||||
|
seeking: boolean = false;
|
||||||
|
private timeToSeek: number | undefined = undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
camera: string,
|
||||||
|
imgController: MutableRefObject<HTMLImageElement | null>,
|
||||||
|
frameTimes: number[],
|
||||||
|
) {
|
||||||
|
super(camera);
|
||||||
|
this.imgController = imgController;
|
||||||
|
this.frameTimes = frameTimes;
|
||||||
|
}
|
||||||
|
|
||||||
|
override scrubToTimestamp(time: number): boolean {
|
||||||
|
if (!this.imgController.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = this.frameTimes.find((p) => {
|
||||||
|
return time <= p;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.seeking) {
|
||||||
|
this.timeToSeek = frame;
|
||||||
|
} else {
|
||||||
|
const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${frame}.webp/thumbnail.webp`;
|
||||||
|
|
||||||
|
if (this.imgController.current.src != newSrc) {
|
||||||
|
this.imgController.current.src = newSrc;
|
||||||
|
this.seeking = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override finishedSeeking() {
|
||||||
|
if (!this.imgController.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timeToSeek) {
|
||||||
|
const newSrc = `${baseUrl}api/preview/preview_${this.camera}-${this.timeToSeek}.webp/thumbnail.webp`;
|
||||||
|
|
||||||
|
if (this.imgController.current.src != newSrc) {
|
||||||
|
this.imgController.current.src = newSrc;
|
||||||
|
} else {
|
||||||
|
this.timeToSeek = undefined;
|
||||||
|
this.seeking = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.seeking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override setNewPreviewStartTime(time: number) {
|
||||||
|
this.timeToSeek = time;
|
||||||
|
}
|
||||||
|
}
|
@ -527,6 +527,7 @@ function InProgressPreview({
|
|||||||
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
`preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${
|
||||||
Math.ceil(review.end_time) + PREVIEW_PADDING
|
Math.ceil(review.end_time) + PREVIEW_PADDING
|
||||||
}/frames`,
|
}/frames`,
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
);
|
);
|
||||||
const [manualFrame, setManualFrame] = useState(false);
|
const [manualFrame, setManualFrame] = useState(false);
|
||||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
||||||
@ -642,7 +643,7 @@ function InProgressPreview({
|
|||||||
<div className="relative size-full flex items-center bg-black">
|
<div className="relative size-full flex items-center bg-black">
|
||||||
<img
|
<img
|
||||||
className="size-full object-contain"
|
className="size-full object-contain"
|
||||||
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.jpg`}
|
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
/>
|
/>
|
||||||
<Slider
|
<Slider
|
||||||
|
@ -1,230 +0,0 @@
|
|||||||
import {
|
|
||||||
MutableRefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { Preview } from "@/types/preview";
|
|
||||||
import { PreviewPlayback } from "@/types/playback";
|
|
||||||
|
|
||||||
type PreviewVideoPlayerProps = {
|
|
||||||
className?: string;
|
|
||||||
camera: string;
|
|
||||||
timeRange: { start: number; end: number };
|
|
||||||
cameraPreviews: Preview[];
|
|
||||||
startTime?: number;
|
|
||||||
onControllerReady: (controller: PreviewVideoController) => void;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
|
||||||
export default function PreviewVideoPlayer({
|
|
||||||
className,
|
|
||||||
camera,
|
|
||||||
timeRange,
|
|
||||||
cameraPreviews,
|
|
||||||
startTime,
|
|
||||||
onControllerReady,
|
|
||||||
onClick,
|
|
||||||
}: PreviewVideoPlayerProps) {
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
|
|
||||||
// controlling playback
|
|
||||||
|
|
||||||
const previewRef = useRef<HTMLVideoElement | null>(null);
|
|
||||||
const controller = useMemo(() => {
|
|
||||||
if (!config || !previewRef.current) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PreviewVideoController(camera, previewRef);
|
|
||||||
// we only care when preview is ready
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [camera, config, previewRef.current]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!controller) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller) {
|
|
||||||
onControllerReady(controller);
|
|
||||||
}
|
|
||||||
// we only want to fire once when players are ready
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [controller]);
|
|
||||||
|
|
||||||
// initial state
|
|
||||||
|
|
||||||
const initialPreview = useMemo(() => {
|
|
||||||
return cameraPreviews.find(
|
|
||||||
(preview) =>
|
|
||||||
preview.camera == camera &&
|
|
||||||
Math.round(preview.start) >= timeRange.start &&
|
|
||||||
Math.floor(preview.end) <= timeRange.end,
|
|
||||||
);
|
|
||||||
|
|
||||||
// we only want to calculate this once
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [currentPreview, setCurrentPreview] = useState(initialPreview);
|
|
||||||
|
|
||||||
const onPreviewSeeked = useCallback(() => {
|
|
||||||
if (!controller) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.finishedSeeking();
|
|
||||||
}, [controller]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!controller) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = cameraPreviews.find(
|
|
||||||
(preview) =>
|
|
||||||
preview.camera == camera &&
|
|
||||||
Math.round(preview.start) >= timeRange.start &&
|
|
||||||
Math.floor(preview.end) <= timeRange.end,
|
|
||||||
);
|
|
||||||
setCurrentPreview(preview);
|
|
||||||
|
|
||||||
controller.newPlayback({
|
|
||||||
preview,
|
|
||||||
timeRange,
|
|
||||||
});
|
|
||||||
|
|
||||||
// we only want this to change when recordings update
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [controller, timeRange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentPreview || !previewRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
previewRef.current.load();
|
|
||||||
}, [currentPreview, previewRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
ref={previewRef}
|
|
||||||
className={`size-full rounded-2xl bg-black`}
|
|
||||||
preload="auto"
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
disableRemotePlayback
|
|
||||||
onSeeked={onPreviewSeeked}
|
|
||||||
onLoadedData={() => {
|
|
||||||
if (controller) {
|
|
||||||
controller.previewReady();
|
|
||||||
} else {
|
|
||||||
previewRef.current?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previewRef.current && startTime && currentPreview) {
|
|
||||||
previewRef.current.currentTime = startTime - currentPreview.start;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentPreview != undefined && (
|
|
||||||
<source src={currentPreview.src} type={currentPreview.type} />
|
|
||||||
)}
|
|
||||||
</video>
|
|
||||||
{cameraPreviews && !currentPreview && (
|
|
||||||
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
|
|
||||||
No Preview Found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PreviewVideoController {
|
|
||||||
// main state
|
|
||||||
public camera = "";
|
|
||||||
private previewRef: MutableRefObject<HTMLVideoElement | null>;
|
|
||||||
private timeRange: { start: number; end: number } | undefined = undefined;
|
|
||||||
|
|
||||||
// preview
|
|
||||||
private preview: Preview | undefined = undefined;
|
|
||||||
private timeToSeek: number | undefined = undefined;
|
|
||||||
private seeking = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
camera: string,
|
|
||||||
previewRef: MutableRefObject<HTMLVideoElement | null>,
|
|
||||||
) {
|
|
||||||
this.camera = camera;
|
|
||||||
this.previewRef = previewRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
newPlayback(newPlayback: PreviewPlayback) {
|
|
||||||
this.preview = newPlayback.preview;
|
|
||||||
this.seeking = false;
|
|
||||||
|
|
||||||
this.timeRange = newPlayback.timeRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrubToTimestamp(time: number): boolean {
|
|
||||||
if (!this.preview || !this.timeRange) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time < this.preview.start || time > this.preview.end) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.seeking) {
|
|
||||||
this.timeToSeek = time;
|
|
||||||
} else {
|
|
||||||
if (this.previewRef.current) {
|
|
||||||
this.previewRef.current.currentTime = Math.max(
|
|
||||||
0,
|
|
||||||
time - this.preview.start,
|
|
||||||
);
|
|
||||||
this.seeking = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setNewPreviewStartTime(time: number) {
|
|
||||||
this.timeToSeek = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
finishedSeeking() {
|
|
||||||
if (!this.previewRef.current || !this.preview) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.timeToSeek &&
|
|
||||||
this.timeToSeek != this.previewRef.current?.currentTime
|
|
||||||
) {
|
|
||||||
this.previewRef.current.currentTime =
|
|
||||||
this.timeToSeek - this.preview.start;
|
|
||||||
} else {
|
|
||||||
this.seeking = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previewReady() {
|
|
||||||
this.seeking = false;
|
|
||||||
this.previewRef.current?.pause();
|
|
||||||
|
|
||||||
if (this.timeToSeek) {
|
|
||||||
this.finishedSeeking();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -163,7 +163,7 @@ export function getChunkedTimeRange(
|
|||||||
while (end < endTimestamp) {
|
while (end < endTimestamp) {
|
||||||
startDay.setHours(startDay.getHours() + 1);
|
startDay.setHours(startDay.getHours() + 1);
|
||||||
|
|
||||||
if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) {
|
if (startDay > endOfThisHour) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,9 +33,9 @@ import { MdCircle } from "react-icons/md";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import PreviewVideoPlayer, {
|
import PreviewPlayer, {
|
||||||
PreviewVideoController,
|
PreviewController,
|
||||||
} from "@/components/player/PreviewVideoPlayer";
|
} from "@/components/player/PreviewPlayer";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviews?: ReviewSegment[];
|
reviews?: ReviewSegment[];
|
||||||
@ -578,9 +578,7 @@ function MotionReview({
|
|||||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||||
}, [config, filter]);
|
}, [config, filter]);
|
||||||
|
|
||||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>(
|
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
// motion data
|
// motion data
|
||||||
|
|
||||||
@ -596,14 +594,9 @@ function MotionReview({
|
|||||||
|
|
||||||
// timeline time
|
// timeline time
|
||||||
|
|
||||||
const lastFullHour = useMemo(() => {
|
|
||||||
const end = new Date(timeRange.before * 1000);
|
|
||||||
end.setMinutes(0, 0, 0);
|
|
||||||
return end.getTime() / 1000;
|
|
||||||
}, [timeRange]);
|
|
||||||
const timeRangeSegments = useMemo(
|
const timeRangeSegments = useMemo(
|
||||||
() => getChunkedTimeRange(timeRange.after, lastFullHour),
|
() => getChunkedTimeRange(timeRange.after, timeRange.before),
|
||||||
[lastFullHour, timeRange],
|
[timeRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialIndex = useMemo(() => {
|
const initialIndex = useMemo(() => {
|
||||||
@ -620,7 +613,7 @@ function MotionReview({
|
|||||||
|
|
||||||
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
const [selectedRangeIdx, setSelectedRangeIdx] = useState(initialIndex);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(
|
const [currentTime, setCurrentTime] = useState<number>(
|
||||||
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.start,
|
startTime ?? timeRangeSegments.ranges[selectedRangeIdx]?.end,
|
||||||
);
|
);
|
||||||
const currentTimeRange = useMemo(
|
const currentTimeRange = useMemo(
|
||||||
() => timeRangeSegments.ranges[selectedRangeIdx],
|
() => timeRangeSegments.ranges[selectedRangeIdx],
|
||||||
@ -674,7 +667,7 @@ function MotionReview({
|
|||||||
grow = "aspect-video";
|
grow = "aspect-video";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<PreviewVideoPlayer
|
<PreviewPlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${grow}`}
|
className={`${grow}`}
|
||||||
camera={camera.name}
|
camera={camera.name}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import DynamicVideoPlayer, {
|
import DynamicVideoPlayer, {
|
||||||
DynamicVideoController,
|
DynamicVideoController,
|
||||||
} from "@/components/player/DynamicVideoPlayer";
|
} from "@/components/player/DynamicVideoPlayer";
|
||||||
|
import PreviewPlayer, {
|
||||||
|
PreviewController,
|
||||||
|
} from "@/components/player/PreviewPlayer";
|
||||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -11,6 +14,7 @@ import {
|
|||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||||
@ -37,15 +41,15 @@ export function DesktopRecordingView({
|
|||||||
allCameras,
|
allCameras,
|
||||||
allPreviews,
|
allPreviews,
|
||||||
}: DesktopRecordingViewProps) {
|
}: DesktopRecordingViewProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// controller state
|
// controller state
|
||||||
|
|
||||||
const [mainCamera, setMainCamera] = useState(startCamera);
|
const [mainCamera, setMainCamera] = useState(startCamera);
|
||||||
const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>(
|
const mainControllerRef = useRef<DynamicVideoController | null>(null);
|
||||||
{},
|
const previewRefs = useRef<{ [camera: string]: PreviewController }>({});
|
||||||
);
|
|
||||||
|
|
||||||
const [playbackStart, setPlaybackStart] = useState(startTime);
|
const [playbackStart, setPlaybackStart] = useState(startTime);
|
||||||
|
|
||||||
@ -64,27 +68,20 @@ export function DesktopRecordingView({
|
|||||||
|
|
||||||
// move to next clip
|
// move to next clip
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!mainControllerRef.current) {
|
||||||
!videoPlayersRef.current &&
|
|
||||||
Object.values(videoPlayersRef.current).length > 0
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainController = videoPlayersRef.current[mainCamera];
|
mainControllerRef.current.onClipChangedEvent((dir) => {
|
||||||
|
|
||||||
if (mainController) {
|
|
||||||
mainController.onClipChangedEvent((dir) => {
|
|
||||||
if (dir == "forward") {
|
if (dir == "forward") {
|
||||||
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
if (selectedRangeIdx < timeRange.ranges.length - 1) {
|
||||||
setSelectedRangeIdx(selectedRangeIdx + 1);
|
setSelectedRangeIdx(selectedRangeIdx + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
// we only want to fire once when players are ready
|
// we only want to fire once when players are ready
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedRangeIdx, timeRange, videoPlayersRef.current, mainCamera]);
|
}, [selectedRangeIdx, timeRange, mainControllerRef.current, mainCamera]);
|
||||||
|
|
||||||
// scrubbing and timeline state
|
// scrubbing and timeline state
|
||||||
|
|
||||||
@ -107,7 +104,9 @@ export function DesktopRecordingView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.values(videoPlayersRef.current).forEach((controller) => {
|
mainControllerRef.current?.scrubToTimestamp(currentTime);
|
||||||
|
|
||||||
|
Object.values(previewRefs.current).forEach((controller) => {
|
||||||
controller.scrubToTimestamp(currentTime);
|
controller.scrubToTimestamp(currentTime);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -115,7 +114,7 @@ export function DesktopRecordingView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrubbing) {
|
if (!scrubbing) {
|
||||||
videoPlayersRef.current[mainCamera]?.seekToTimestamp(currentTime, true);
|
mainControllerRef.current?.seekToTimestamp(currentTime, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only want to seek when user stops scrubbing
|
// we only want to seek when user stops scrubbing
|
||||||
@ -124,27 +123,10 @@ export function DesktopRecordingView({
|
|||||||
|
|
||||||
const onSelectCamera = useCallback(
|
const onSelectCamera = useCallback(
|
||||||
(newCam: string) => {
|
(newCam: string) => {
|
||||||
const lastController = videoPlayersRef.current[mainCamera];
|
|
||||||
const newController = videoPlayersRef.current[newCam];
|
|
||||||
lastController.onPlayerTimeUpdate(null);
|
|
||||||
lastController.onClipChangedEvent(null);
|
|
||||||
lastController.scrubToTimestamp(currentTime);
|
|
||||||
newController.onPlayerTimeUpdate((timestamp: number) => {
|
|
||||||
setCurrentTime(timestamp);
|
|
||||||
|
|
||||||
allCameras.forEach((cam) => {
|
|
||||||
if (cam != newCam) {
|
|
||||||
videoPlayersRef.current[cam]?.scrubToTimestamp(
|
|
||||||
Math.floor(timestamp),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
newController.seekToTimestamp(currentTime, true);
|
|
||||||
setPlaybackStart(currentTime);
|
|
||||||
setMainCamera(newCam);
|
setMainCamera(newCam);
|
||||||
|
setPlaybackStart(currentTime);
|
||||||
},
|
},
|
||||||
[allCameras, currentTime, mainCamera],
|
[currentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
// motion timeline data
|
// motion timeline data
|
||||||
@ -162,6 +144,21 @@ export function DesktopRecordingView({
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const grow = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return "aspect-video";
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio =
|
||||||
|
config.cameras[mainCamera].detect.width /
|
||||||
|
config.cameras[mainCamera].detect.height;
|
||||||
|
if (aspectRatio > 2) {
|
||||||
|
return "aspect-wide";
|
||||||
|
} else {
|
||||||
|
return "aspect-video";
|
||||||
|
}
|
||||||
|
}, [config, mainCamera]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="relative size-full">
|
<div ref={contentRef} className="relative size-full">
|
||||||
<Button
|
<Button
|
||||||
@ -174,47 +171,39 @@ export function DesktopRecordingView({
|
|||||||
|
|
||||||
<div className="flex h-full justify-center overflow-hidden">
|
<div className="flex h-full justify-center overflow-hidden">
|
||||||
<div className="flex flex-1 flex-wrap">
|
<div className="flex flex-1 flex-wrap">
|
||||||
<div className="flex flex-col h-full px-2 justify-end">
|
<div className="w-full flex flex-col h-full px-2 justify-center items-center">
|
||||||
<div key={mainCamera} className="flex justify-center mb-5">
|
<div
|
||||||
|
key={mainCamera}
|
||||||
|
className="w-[82%] flex justify-center items mb-5"
|
||||||
|
>
|
||||||
<DynamicVideoPlayer
|
<DynamicVideoPlayer
|
||||||
className="w-[85%]"
|
className={`w-full ${grow}`}
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
startTime={playbackStart}
|
startTime={playbackStart}
|
||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
videoPlayersRef.current[mainCamera] = controller;
|
mainControllerRef.current = controller;
|
||||||
controller.onPlayerTimeUpdate((timestamp: number) => {
|
controller.onPlayerTimeUpdate((timestamp: number) => {
|
||||||
setCurrentTime(timestamp);
|
setCurrentTime(timestamp);
|
||||||
|
|
||||||
allCameras.forEach((otherCam) => {
|
|
||||||
if (mainCamera != otherCam) {
|
|
||||||
videoPlayersRef.current[otherCam]?.scrubToTimestamp(
|
|
||||||
Math.floor(timestamp),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 overflow-x-auto">
|
<div className="w-full flex justify-center gap-2 overflow-x-auto">
|
||||||
{allCameras.map((cam) => {
|
{allCameras.map((cam) => {
|
||||||
if (cam !== mainCamera) {
|
if (cam !== mainCamera) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={cam}>
|
||||||
key={cam}
|
<PreviewPlayer
|
||||||
className="aspect-video flex items-center we-[300px]"
|
|
||||||
>
|
|
||||||
<DynamicVideoPlayer
|
|
||||||
className="size-full"
|
className="size-full"
|
||||||
camera={cam}
|
camera={cam}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
previewOnly
|
startTime={startTime}
|
||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
videoPlayersRef.current[cam] = controller;
|
previewRefs.current[cam] = controller;
|
||||||
controller.scrubToTimestamp(startTime, true);
|
controller.scrubToTimestamp(startTime);
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelectCamera(cam)}
|
onClick={() => onSelectCamera(cam)}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user