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("_", " ")} {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)}

View File

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

View File

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

View File

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

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

View File

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