mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 11:45:24 +03:00
Use browser history to allow back button to close timeline viewer
This commit is contained in:
parent
6850637343
commit
efb98f725f
@ -72,10 +72,10 @@ export default function HistoryCard({
|
||||
{timeline.camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
<div className="my-2 text-sm font-medium">Activity:</div>
|
||||
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||
{Object.entries(timeline.entries).map(([_, entry], idx) => {
|
||||
return (
|
||||
<div
|
||||
key={entry.timestamp}
|
||||
key={idx}
|
||||
className="flex text-xs capitalize my-1 items-center"
|
||||
>
|
||||
{getTimelineIcon(entry)}
|
||||
|
||||
@ -92,8 +92,6 @@ export default function PreviewThumbnailPlayer({
|
||||
{
|
||||
threshold: 1.0,
|
||||
root: document.getElementById("pageRoot"),
|
||||
// iOS has bug where poster is empty frame until video starts playing so playback needs to begin earlier
|
||||
rootMargin: isSafari ? "10% 0px 25% 0px" : "0px",
|
||||
}
|
||||
);
|
||||
if (node) autoPlayObserver.current.observe(node);
|
||||
@ -132,7 +130,7 @@ export default function PreviewThumbnailPlayer({
|
||||
<VideoPlayer
|
||||
options={{
|
||||
preload: "auto",
|
||||
autoplay: false,
|
||||
autoplay: true,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loadingSpinner: false,
|
||||
@ -146,6 +144,7 @@ export default function PreviewThumbnailPlayer({
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
player.pause(); // autoplay + pause is required for iOS
|
||||
player.playbackRate(isSafari ? 2 : 8);
|
||||
player.currentTime(startTs - relevantPreview.start);
|
||||
}}
|
||||
|
||||
@ -74,6 +74,7 @@ const domEvents: TimelineEventsWithMissing[] = [
|
||||
];
|
||||
|
||||
type ActivityScrubberProps = {
|
||||
className?: string;
|
||||
items?: TimelineItem[];
|
||||
timeBars?: { time: DateType; id?: IdType | undefined }[];
|
||||
groups?: TimelineGroup[];
|
||||
@ -81,6 +82,7 @@ type ActivityScrubberProps = {
|
||||
} & TimelineEventsHandlers;
|
||||
|
||||
function ActivityScrubber({
|
||||
className,
|
||||
items,
|
||||
timeBars,
|
||||
groups,
|
||||
@ -159,7 +161,7 @@ function ActivityScrubber({
|
||||
return () => {
|
||||
timelineInstance.destroy();
|
||||
};
|
||||
}, []);
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timelineRef.current.timeline) {
|
||||
@ -184,7 +186,11 @@ function ActivityScrubber({
|
||||
if (items) timelineRef.current.timeline.setItems(items);
|
||||
}, [items, groups, options, currentTime, eventHandlers]);
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
return (
|
||||
<div className={className || ""}>
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityScrubber;
|
||||
|
||||
@ -21,6 +21,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
xs: "h-6 rounded-md",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
|
||||
20
web/src/hooks/use-overlay-state.tsx
Normal file
20
web/src/hooks/use-overlay-state.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
export default function useOverlayState(key: string) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentLocationState = location.state;
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: string) => {
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(location.pathname, { state: newLocationState });
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const overlayStateValue = location.state && location.state[key];
|
||||
return [overlayStateValue, setOverlayStateValue];
|
||||
}
|
||||
@ -22,6 +22,9 @@ import HistoryCardView from "@/views/history/HistoryCardView";
|
||||
import HistoryTimelineView from "@/views/history/HistoryTimelineView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IoMdArrowBack } from "react-icons/io";
|
||||
import useOverlayState from "@/hooks/use-overlay-state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const API_LIMIT = 200;
|
||||
|
||||
@ -81,10 +84,24 @@ function History() {
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [playback, setPlayback] = useState<TimelinePlayback | undefined>();
|
||||
const [viewingPlayback, setViewingPlayback] = useOverlayState("timeline");
|
||||
const setPlaybackState = useCallback(
|
||||
(playback: TimelinePlayback | undefined) => {
|
||||
if (playback == undefined) {
|
||||
setPlayback(undefined);
|
||||
navigate(-1);
|
||||
} else {
|
||||
setPlayback(playback);
|
||||
setViewingPlayback(true);
|
||||
}
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const shouldAutoPlay = useMemo(() => {
|
||||
return playback == undefined && window.innerWidth < 480;
|
||||
const isMobile = useMemo(() => {
|
||||
return window.innerWidth < 768;
|
||||
}, [playback]);
|
||||
|
||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||
@ -142,12 +159,13 @@ function History() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
{playback != undefined && (
|
||||
<div className="flex justify-start">
|
||||
{viewingPlayback && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setPlayback(undefined)}
|
||||
onClick={() => setPlaybackState(undefined)}
|
||||
>
|
||||
<IoMdArrowBack className="w-6 h-6" />
|
||||
</Button>
|
||||
@ -186,27 +204,51 @@ function History() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<>
|
||||
{playback == undefined && (
|
||||
<HistoryCardView
|
||||
timelineCards={timelineCards}
|
||||
allPreviews={allPreviews}
|
||||
isMobileView={shouldAutoPlay}
|
||||
isValidating={isValidating}
|
||||
isDone={isDone}
|
||||
onNextPage={() => {
|
||||
setSize(size + 1);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
onItemSelected={(item) => setPlayback(item)}
|
||||
/>
|
||||
)}
|
||||
{playback != undefined && (
|
||||
<HistoryTimelineView playback={playback} isMobile={shouldAutoPlay} />
|
||||
)}
|
||||
</>
|
||||
<HistoryCardView
|
||||
timelineCards={timelineCards}
|
||||
allPreviews={allPreviews}
|
||||
isMobile={isMobile}
|
||||
isValidating={isValidating}
|
||||
isDone={isDone}
|
||||
onNextPage={() => {
|
||||
setSize(size + 1);
|
||||
}}
|
||||
onDelete={onDelete}
|
||||
onItemSelected={(item) => setPlaybackState(item)}
|
||||
/>
|
||||
<TimelineViewer
|
||||
playback={viewingPlayback ? playback : undefined}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setPlaybackState(undefined)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineViewerProps = {
|
||||
playback: TimelinePlayback | undefined;
|
||||
isMobile: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) {
|
||||
if (isMobile) {
|
||||
return playback != undefined ? (
|
||||
<div className="w-screen absolute left-0 top-20 bottom-0 bg-background z-50">
|
||||
<HistoryTimelineView playback={playback} isMobile={isMobile} />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={playback != undefined} onOpenChange={(_) => onClose()}>
|
||||
<DialogContent className="w-3/5 max-w-full">
|
||||
{playback && (
|
||||
<HistoryTimelineView playback={playback} isMobile={isMobile} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default History;
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
getTimelineIcon,
|
||||
} from "@/utils/timelineUtil";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import Player from "video.js/dist/types/player";
|
||||
|
||||
@ -41,6 +41,8 @@ export default function HistoryTimelineView({
|
||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const [seeking, setSeeking] = useState(false);
|
||||
const [timeToSeek, setTimeToSeek] = useState<number | undefined>(undefined);
|
||||
|
||||
const annotationOffset = useMemo(() => {
|
||||
@ -66,7 +68,10 @@ export default function HistoryTimelineView({
|
||||
const startTime = date.getTime() / 1000;
|
||||
date.setHours(date.getHours() + 1);
|
||||
const endTime = date.getTime() / 1000;
|
||||
return { start: startTime.toFixed(1), end: endTime.toFixed(1) };
|
||||
return {
|
||||
start: parseInt(startTime.toFixed(1)),
|
||||
end: parseInt(endTime.toFixed(1)),
|
||||
};
|
||||
}, [timelineTime]);
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
@ -85,7 +90,7 @@ export default function HistoryTimelineView({
|
||||
return "";
|
||||
}
|
||||
|
||||
const date = new Date(parseInt(playbackTimes.start) * 1000);
|
||||
const date = new Date(playbackTimes.start * 1000);
|
||||
return `${apiHost}vod/${date.getFullYear()}-${
|
||||
date.getMonth() + 1
|
||||
}/${date.getDate()}/${date.getHours()}/${
|
||||
@ -129,41 +134,53 @@ export default function HistoryTimelineView({
|
||||
[annotationOffset, recordings, playerRef]
|
||||
);
|
||||
|
||||
const onScrubTime = (data: { time: Date }) => {
|
||||
if (!hasRelevantPreview) {
|
||||
const onScrubTime = useCallback(
|
||||
(data: { time: Date }) => {
|
||||
if (!hasRelevantPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerRef.current?.paused() == false) {
|
||||
setScrubbing(true);
|
||||
playerRef.current?.pause();
|
||||
}
|
||||
|
||||
const seekTimestamp = data.time.getTime() / 1000;
|
||||
const seekTime = seekTimestamp - playback.relevantPreview!!.start;
|
||||
setTimeToSeek(Math.round(seekTime));
|
||||
},
|
||||
[scrubbing, playerRef]
|
||||
);
|
||||
|
||||
const onStopScrubbing = useCallback(
|
||||
(data: { time: Date }) => {
|
||||
const playbackTime = data.time.getTime() / 1000;
|
||||
playerRef.current?.currentTime(playbackTime - playbackTimes.start);
|
||||
setScrubbing(false);
|
||||
playerRef.current?.play();
|
||||
},
|
||||
[playerRef]
|
||||
);
|
||||
|
||||
// handle seeking to next frame when seek is finished
|
||||
useEffect(() => {
|
||||
if (seeking) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scrubbing) {
|
||||
playerRef.current?.pause();
|
||||
setScrubbing(true);
|
||||
if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) {
|
||||
setSeeking(true);
|
||||
previewRef.current?.currentTime(timeToSeek);
|
||||
}
|
||||
|
||||
const seekTimestamp = data.time.getTime() / 1000;
|
||||
const seekTime = seekTimestamp - playback.relevantPreview!!.start;
|
||||
if (timeToSeek != undefined) {
|
||||
setTimeToSeek(seekTime);
|
||||
} else {
|
||||
setTimeToSeek(seekTime);
|
||||
previewRef.current?.currentTime(seekTime);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
if (timeToSeek && playerRef.current?.currentTime() != timeToSeek) {
|
||||
playerRef.current?.currentTime(timeToSeek);
|
||||
}
|
||||
|
||||
setTimeToSeek(undefined);
|
||||
};
|
||||
}, [timeToSeek, seeking]);
|
||||
|
||||
if (!config || !recordings) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<>
|
||||
<div
|
||||
className={`relative ${
|
||||
hasRelevantPreview && scrubbing ? "hidden" : "visible"
|
||||
@ -183,7 +200,7 @@ export default function HistoryTimelineView({
|
||||
seekOptions={{ forward: 10, backward: 5 }}
|
||||
onReady={(player) => {
|
||||
playerRef.current = player;
|
||||
player.currentTime(timelineTime - parseInt(playbackTimes.start));
|
||||
player.currentTime(timelineTime - playbackTimes.start);
|
||||
player.on("playing", () => {
|
||||
setFocusedItem(undefined);
|
||||
});
|
||||
@ -219,7 +236,7 @@ export default function HistoryTimelineView({
|
||||
seekOptions={{}}
|
||||
onReady={(player) => {
|
||||
previewRef.current = player;
|
||||
player.on("seeked", onSeeked);
|
||||
player.on("seeked", () => setSeeking(false));
|
||||
}}
|
||||
onDispose={() => {
|
||||
previewRef.current = undefined;
|
||||
@ -227,36 +244,37 @@ export default function HistoryTimelineView({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="m-1">
|
||||
{playback != undefined && (
|
||||
<ActivityScrubber
|
||||
items={timelineItemsToScrubber(playback.timelineItems)}
|
||||
timeBars={[{ time: new Date(timelineTime * 1000), id: "playback" }]}
|
||||
timeBars={
|
||||
hasRelevantPreview
|
||||
? [{ time: new Date(timelineTime * 1000), id: "playback" }]
|
||||
: []
|
||||
}
|
||||
options={{
|
||||
...(isMobile && {
|
||||
start: new Date((timelineTime - 300) * 1000),
|
||||
end: new Date((timelineTime + 300) * 1000),
|
||||
start: new Date(
|
||||
Math.max(playbackTimes.start, timelineTime - 300) * 1000
|
||||
),
|
||||
end: new Date(
|
||||
Math.min(playbackTimes.end, timelineTime + 300) * 1000
|
||||
),
|
||||
}),
|
||||
snap: null,
|
||||
min: new Date(parseInt(playbackTimes.start) * 1000),
|
||||
max: new Date(parseInt(playbackTimes.end) * 1000),
|
||||
timeAxis: isMobile ? { scale: "minute" } : {},
|
||||
min: new Date(playbackTimes.start * 1000),
|
||||
max: new Date(playbackTimes.end * 1000),
|
||||
timeAxis: isMobile ? { scale: "minute", step: 5 } : {},
|
||||
}}
|
||||
timechangeHandler={onScrubTime}
|
||||
timechangedHandler={(data) => {
|
||||
const playbackTime = data.time.getTime() / 1000;
|
||||
playerRef.current?.currentTime(
|
||||
playbackTime - parseInt(playbackTimes.start)
|
||||
);
|
||||
setScrubbing(false);
|
||||
playerRef.current?.play();
|
||||
}}
|
||||
timechangedHandler={onStopScrubbing}
|
||||
selectHandler={onSelectItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user