Use browser history to allow back button to close timeline viewer

This commit is contained in:
Nick Mowen 2023-12-28 06:55:54 -07:00
parent 6850637343
commit efb98f725f
7 changed files with 164 additions and 78 deletions

View File

@ -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)}

View File

@ -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);
}}

View File

@ -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;

View File

@ -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",

View 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];
}

View File

@ -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;

View File

@ -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>
);
}