diff --git a/web/src/components/card/HistoryCard.tsx b/web/src/components/card/HistoryCard.tsx index 3cb151bf8..cc875b654 100644 --- a/web/src/components/card/HistoryCard.tsx +++ b/web/src/components/card/HistoryCard.tsx @@ -72,10 +72,10 @@ export default function HistoryCard({ {timeline.camera.replaceAll("_", " ")}
Activity:
- {Object.entries(timeline.entries).map(([_, entry]) => { + {Object.entries(timeline.entries).map(([_, entry], idx) => { return (
{getTimelineIcon(entry)} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 871ff0e9e..db11d2daa 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -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({ { playerRef.current = player; + player.pause(); // autoplay + pause is required for iOS player.playbackRate(isSafari ? 2 : 8); player.currentTime(startTs - relevantPreview.start); }} diff --git a/web/src/components/scrubber/ActivityScrubber.tsx b/web/src/components/scrubber/ActivityScrubber.tsx index 35fcc3e90..519647319 100644 --- a/web/src/components/scrubber/ActivityScrubber.tsx +++ b/web/src/components/scrubber/ActivityScrubber.tsx @@ -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
; + return ( +
+
+
+ ); } export default ActivityScrubber; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index de31d9031..e51f78f89 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -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", diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx new file mode 100644 index 000000000..ee4ccaeca --- /dev/null +++ b/web/src/hooks/use-overlay-state.tsx @@ -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]; +} diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index 502a45550..cc0bca56a 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -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(); + 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 ( <>
-
- {playback != undefined && ( +
+ {viewingPlayback && ( @@ -186,27 +204,51 @@ function History() { - <> - {playback == undefined && ( - { - setSize(size + 1); - }} - onDelete={onDelete} - onItemSelected={(item) => setPlayback(item)} - /> - )} - {playback != undefined && ( - - )} - + { + setSize(size + 1); + }} + onDelete={onDelete} + onItemSelected={(item) => setPlaybackState(item)} + /> + setPlaybackState(undefined)} + /> ); } +type TimelineViewerProps = { + playback: TimelinePlayback | undefined; + isMobile: boolean; + onClose: () => void; +}; + +function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) { + if (isMobile) { + return playback != undefined ? ( +
+ +
+ ) : null; + } + + return ( + onClose()}> + + {playback && ( + + )} + + + ); +} + export default History; diff --git a/web/src/views/history/HistoryTimelineView.tsx b/web/src/views/history/HistoryTimelineView.tsx index bd96792e5..98a4baca1 100644 --- a/web/src/views/history/HistoryTimelineView.tsx +++ b/web/src/views/history/HistoryTimelineView.tsx @@ -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( undefined ); + + const [seeking, setSeeking] = useState(false); const [timeToSeek, setTimeToSeek] = useState(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 ; } return ( - <> -
+
+ <>
{ 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({ />
)} -
+
{playback != undefined && ( { - const playbackTime = data.time.getTime() / 1000; - playerRef.current?.currentTime( - playbackTime - parseInt(playbackTimes.start) - ); - setScrubbing(false); - playerRef.current?.play(); - }} + timechangedHandler={onStopScrubbing} selectHandler={onSelectItem} /> )}
- +
); }