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("_", " ")}
|
{timeline.camera.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
<div className="my-2 text-sm font-medium">Activity:</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.timestamp}
|
key={idx}
|
||||||
className="flex text-xs capitalize my-1 items-center"
|
className="flex text-xs capitalize my-1 items-center"
|
||||||
>
|
>
|
||||||
{getTimelineIcon(entry)}
|
{getTimelineIcon(entry)}
|
||||||
|
|||||||
@ -92,8 +92,6 @@ export default function PreviewThumbnailPlayer({
|
|||||||
{
|
{
|
||||||
threshold: 1.0,
|
threshold: 1.0,
|
||||||
root: document.getElementById("pageRoot"),
|
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);
|
if (node) autoPlayObserver.current.observe(node);
|
||||||
@ -132,7 +130,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
options={{
|
options={{
|
||||||
preload: "auto",
|
preload: "auto",
|
||||||
autoplay: false,
|
autoplay: true,
|
||||||
controls: false,
|
controls: false,
|
||||||
muted: true,
|
muted: true,
|
||||||
loadingSpinner: false,
|
loadingSpinner: false,
|
||||||
@ -146,6 +144,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
seekOptions={{}}
|
seekOptions={{}}
|
||||||
onReady={(player) => {
|
onReady={(player) => {
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
|
player.pause(); // autoplay + pause is required for iOS
|
||||||
player.playbackRate(isSafari ? 2 : 8);
|
player.playbackRate(isSafari ? 2 : 8);
|
||||||
player.currentTime(startTs - relevantPreview.start);
|
player.currentTime(startTs - relevantPreview.start);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -74,6 +74,7 @@ const domEvents: TimelineEventsWithMissing[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type ActivityScrubberProps = {
|
type ActivityScrubberProps = {
|
||||||
|
className?: string;
|
||||||
items?: TimelineItem[];
|
items?: TimelineItem[];
|
||||||
timeBars?: { time: DateType; id?: IdType | undefined }[];
|
timeBars?: { time: DateType; id?: IdType | undefined }[];
|
||||||
groups?: TimelineGroup[];
|
groups?: TimelineGroup[];
|
||||||
@ -81,6 +82,7 @@ type ActivityScrubberProps = {
|
|||||||
} & TimelineEventsHandlers;
|
} & TimelineEventsHandlers;
|
||||||
|
|
||||||
function ActivityScrubber({
|
function ActivityScrubber({
|
||||||
|
className,
|
||||||
items,
|
items,
|
||||||
timeBars,
|
timeBars,
|
||||||
groups,
|
groups,
|
||||||
@ -159,7 +161,7 @@ function ActivityScrubber({
|
|||||||
return () => {
|
return () => {
|
||||||
timelineInstance.destroy();
|
timelineInstance.destroy();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [containerRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timelineRef.current.timeline) {
|
if (!timelineRef.current.timeline) {
|
||||||
@ -184,7 +186,11 @@ function ActivityScrubber({
|
|||||||
if (items) timelineRef.current.timeline.setItems(items);
|
if (items) timelineRef.current.timeline.setItems(items);
|
||||||
}, [items, groups, options, currentTime, eventHandlers]);
|
}, [items, groups, options, currentTime, eventHandlers]);
|
||||||
|
|
||||||
return <div ref={containerRef} />;
|
return (
|
||||||
|
<div className={className || ""}>
|
||||||
|
<div ref={containerRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ActivityScrubber;
|
export default ActivityScrubber;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
xs: "h-6 rounded-md",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: "h-10 w-10",
|
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 HistoryTimelineView from "@/views/history/HistoryTimelineView";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { IoMdArrowBack } from "react-icons/io";
|
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;
|
const API_LIMIT = 200;
|
||||||
|
|
||||||
@ -81,10 +84,24 @@ function History() {
|
|||||||
{ revalidateOnFocus: false }
|
{ revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const [playback, setPlayback] = useState<TimelinePlayback | undefined>();
|
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(() => {
|
const isMobile = useMemo(() => {
|
||||||
return playback == undefined && window.innerWidth < 480;
|
return window.innerWidth < 768;
|
||||||
}, [playback]);
|
}, [playback]);
|
||||||
|
|
||||||
const timelineCards: CardsData | never[] = useMemo(() => {
|
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||||
@ -142,12 +159,13 @@ function History() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="flex">
|
<div className="flex justify-start">
|
||||||
{playback != undefined && (
|
{viewingPlayback && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
className="mt-2"
|
||||||
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setPlayback(undefined)}
|
onClick={() => setPlaybackState(undefined)}
|
||||||
>
|
>
|
||||||
<IoMdArrowBack className="w-6 h-6" />
|
<IoMdArrowBack className="w-6 h-6" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -186,27 +204,51 @@ function History() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
<>
|
|
||||||
{playback == undefined && (
|
|
||||||
<HistoryCardView
|
<HistoryCardView
|
||||||
timelineCards={timelineCards}
|
timelineCards={timelineCards}
|
||||||
allPreviews={allPreviews}
|
allPreviews={allPreviews}
|
||||||
isMobileView={shouldAutoPlay}
|
isMobile={isMobile}
|
||||||
isValidating={isValidating}
|
isValidating={isValidating}
|
||||||
isDone={isDone}
|
isDone={isDone}
|
||||||
onNextPage={() => {
|
onNextPage={() => {
|
||||||
setSize(size + 1);
|
setSize(size + 1);
|
||||||
}}
|
}}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onItemSelected={(item) => setPlayback(item)}
|
onItemSelected={(item) => setPlaybackState(item)}
|
||||||
|
/>
|
||||||
|
<TimelineViewer
|
||||||
|
playback={viewingPlayback ? playback : undefined}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onClose={() => setPlaybackState(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{playback != undefined && (
|
|
||||||
<HistoryTimelineView playback={playback} isMobile={shouldAutoPlay} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
export default History;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
getTimelineIcon,
|
getTimelineIcon,
|
||||||
} from "@/utils/timelineUtil";
|
} from "@/utils/timelineUtil";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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 useSWR from "swr";
|
||||||
import Player from "video.js/dist/types/player";
|
import Player from "video.js/dist/types/player";
|
||||||
|
|
||||||
@ -41,6 +41,8 @@ export default function HistoryTimelineView({
|
|||||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [seeking, setSeeking] = useState(false);
|
||||||
const [timeToSeek, setTimeToSeek] = useState<number | undefined>(undefined);
|
const [timeToSeek, setTimeToSeek] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const annotationOffset = useMemo(() => {
|
const annotationOffset = useMemo(() => {
|
||||||
@ -66,7 +68,10 @@ export default function HistoryTimelineView({
|
|||||||
const startTime = date.getTime() / 1000;
|
const startTime = date.getTime() / 1000;
|
||||||
date.setHours(date.getHours() + 1);
|
date.setHours(date.getHours() + 1);
|
||||||
const endTime = date.getTime() / 1000;
|
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]);
|
}, [timelineTime]);
|
||||||
|
|
||||||
const recordingParams = useMemo(() => {
|
const recordingParams = useMemo(() => {
|
||||||
@ -85,7 +90,7 @@ export default function HistoryTimelineView({
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(parseInt(playbackTimes.start) * 1000);
|
const date = new Date(playbackTimes.start * 1000);
|
||||||
return `${apiHost}vod/${date.getFullYear()}-${
|
return `${apiHost}vod/${date.getFullYear()}-${
|
||||||
date.getMonth() + 1
|
date.getMonth() + 1
|
||||||
}/${date.getDate()}/${date.getHours()}/${
|
}/${date.getDate()}/${date.getHours()}/${
|
||||||
@ -129,41 +134,53 @@ export default function HistoryTimelineView({
|
|||||||
[annotationOffset, recordings, playerRef]
|
[annotationOffset, recordings, playerRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScrubTime = (data: { time: Date }) => {
|
const onScrubTime = useCallback(
|
||||||
|
(data: { time: Date }) => {
|
||||||
if (!hasRelevantPreview) {
|
if (!hasRelevantPreview) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scrubbing) {
|
if (playerRef.current?.paused() == false) {
|
||||||
playerRef.current?.pause();
|
|
||||||
setScrubbing(true);
|
setScrubbing(true);
|
||||||
|
playerRef.current?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTimestamp = data.time.getTime() / 1000;
|
const seekTimestamp = data.time.getTime() / 1000;
|
||||||
const seekTime = seekTimestamp - playback.relevantPreview!!.start;
|
const seekTime = seekTimestamp - playback.relevantPreview!!.start;
|
||||||
if (timeToSeek != undefined) {
|
setTimeToSeek(Math.round(seekTime));
|
||||||
setTimeToSeek(seekTime);
|
},
|
||||||
} else {
|
[scrubbing, playerRef]
|
||||||
setTimeToSeek(seekTime);
|
);
|
||||||
previewRef.current?.currentTime(seekTime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSeeked = () => {
|
const onStopScrubbing = useCallback(
|
||||||
if (timeToSeek && playerRef.current?.currentTime() != timeToSeek) {
|
(data: { time: Date }) => {
|
||||||
playerRef.current?.currentTime(timeToSeek);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeToSeek(undefined);
|
if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) {
|
||||||
};
|
setSeeking(true);
|
||||||
|
previewRef.current?.currentTime(timeToSeek);
|
||||||
|
}
|
||||||
|
}, [timeToSeek, seeking]);
|
||||||
|
|
||||||
if (!config || !recordings) {
|
if (!config || !recordings) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full">
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<div
|
<div
|
||||||
className={`relative ${
|
className={`relative ${
|
||||||
hasRelevantPreview && scrubbing ? "hidden" : "visible"
|
hasRelevantPreview && scrubbing ? "hidden" : "visible"
|
||||||
@ -183,7 +200,7 @@ export default function HistoryTimelineView({
|
|||||||
seekOptions={{ forward: 10, backward: 5 }}
|
seekOptions={{ forward: 10, backward: 5 }}
|
||||||
onReady={(player) => {
|
onReady={(player) => {
|
||||||
playerRef.current = player;
|
playerRef.current = player;
|
||||||
player.currentTime(timelineTime - parseInt(playbackTimes.start));
|
player.currentTime(timelineTime - playbackTimes.start);
|
||||||
player.on("playing", () => {
|
player.on("playing", () => {
|
||||||
setFocusedItem(undefined);
|
setFocusedItem(undefined);
|
||||||
});
|
});
|
||||||
@ -219,7 +236,7 @@ export default function HistoryTimelineView({
|
|||||||
seekOptions={{}}
|
seekOptions={{}}
|
||||||
onReady={(player) => {
|
onReady={(player) => {
|
||||||
previewRef.current = player;
|
previewRef.current = player;
|
||||||
player.on("seeked", onSeeked);
|
player.on("seeked", () => setSeeking(false));
|
||||||
}}
|
}}
|
||||||
onDispose={() => {
|
onDispose={() => {
|
||||||
previewRef.current = undefined;
|
previewRef.current = undefined;
|
||||||
@ -227,36 +244,37 @@ export default function HistoryTimelineView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
<div className="m-1">
|
<div className="m-1">
|
||||||
{playback != undefined && (
|
{playback != undefined && (
|
||||||
<ActivityScrubber
|
<ActivityScrubber
|
||||||
items={timelineItemsToScrubber(playback.timelineItems)}
|
items={timelineItemsToScrubber(playback.timelineItems)}
|
||||||
timeBars={[{ time: new Date(timelineTime * 1000), id: "playback" }]}
|
timeBars={
|
||||||
|
hasRelevantPreview
|
||||||
|
? [{ time: new Date(timelineTime * 1000), id: "playback" }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
options={{
|
options={{
|
||||||
...(isMobile && {
|
...(isMobile && {
|
||||||
start: new Date((timelineTime - 300) * 1000),
|
start: new Date(
|
||||||
end: new Date((timelineTime + 300) * 1000),
|
Math.max(playbackTimes.start, timelineTime - 300) * 1000
|
||||||
|
),
|
||||||
|
end: new Date(
|
||||||
|
Math.min(playbackTimes.end, timelineTime + 300) * 1000
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
snap: null,
|
snap: null,
|
||||||
min: new Date(parseInt(playbackTimes.start) * 1000),
|
min: new Date(playbackTimes.start * 1000),
|
||||||
max: new Date(parseInt(playbackTimes.end) * 1000),
|
max: new Date(playbackTimes.end * 1000),
|
||||||
timeAxis: isMobile ? { scale: "minute" } : {},
|
timeAxis: isMobile ? { scale: "minute", step: 5 } : {},
|
||||||
}}
|
}}
|
||||||
timechangeHandler={onScrubTime}
|
timechangeHandler={onScrubTime}
|
||||||
timechangedHandler={(data) => {
|
timechangedHandler={onStopScrubbing}
|
||||||
const playbackTime = data.time.getTime() / 1000;
|
|
||||||
playerRef.current?.currentTime(
|
|
||||||
playbackTime - parseInt(playbackTimes.start)
|
|
||||||
);
|
|
||||||
setScrubbing(false);
|
|
||||||
playerRef.current?.play();
|
|
||||||
}}
|
|
||||||
selectHandler={onSelectItem}
|
selectHandler={onSelectItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user