diff --git a/web/src/components/player/DynamicVideoPlayer.tsx b/web/src/components/player/DynamicVideoPlayer.tsx index 910c8cc79..d3ef4f9e0 100644 --- a/web/src/components/player/DynamicVideoPlayer.tsx +++ b/web/src/components/player/DynamicVideoPlayer.tsx @@ -10,6 +10,16 @@ import { Recording } from "@/types/record"; import { Preview } from "@/types/preview"; import { DynamicPlayback } from "@/types/playback"; import PreviewPlayer, { PreviewController } from "./PreviewPlayer"; +import { isDesktop, isMobile } from "react-device-detect"; +import { LuPause, LuPlay } from "react-icons/lu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { MdForward10, MdReplay10 } from "react-icons/md"; type PlayerMode = "playback" | "scrubbing"; @@ -21,20 +31,16 @@ type DynamicVideoPlayerProps = { camera: string; timeRange: { start: number; end: number }; cameraPreviews: Preview[]; - previewOnly?: boolean; startTime?: number; onControllerReady: (controller: DynamicVideoController) => void; - onClick?: () => void; }; export default function DynamicVideoPlayer({ className, camera, timeRange, cameraPreviews, - previewOnly = false, startTime, onControllerReady, - onClick, }: DynamicVideoPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -57,7 +63,9 @@ export default function DynamicVideoPlayer({ const [playerRef, setPlayerRef] = useState(null); const [previewController, setPreviewController] = useState(null); - const [isScrubbing, setIsScrubbing] = useState(previewOnly); + const [controls, setControls] = useState(false); + const [controlsOpen, setControlsOpen] = useState(false); + const [isScrubbing, setIsScrubbing] = useState(false); const [focusedItem, setFocusedItem] = useState( undefined, ); @@ -71,7 +79,7 @@ export default function DynamicVideoPlayer({ playerRef, previewController, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, - previewOnly ? "scrubbing" : "playback", + "playback", setIsScrubbing, setFocusedItem, ); @@ -96,7 +104,7 @@ export default function DynamicVideoPlayer({ const onKeyboardShortcut = useCallback( (key: string, down: boolean, repeat: boolean) => { - if (!playerRef || previewOnly) { + if (!playerRef) { return; } @@ -137,7 +145,7 @@ export default function DynamicVideoPlayer({ }, // only update when preview only changes // eslint-disable-next-line react-hooks/exhaustive-deps - [playerRef, previewOnly], + [playerRef], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "m", " "], @@ -164,13 +172,6 @@ export default function DynamicVideoPlayer({ return; } - if (previewOnly) { - player.autoplay(false); - return; - } - - player.autoplay(true); - if (!startTime) { return; } @@ -189,7 +190,7 @@ export default function DynamicVideoPlayer({ }; // we only want to calculate this once // eslint-disable-next-line react-hooks/exhaustive-deps - }, [previewOnly, startTime, controller]); + }, [startTime, controller]); // state of playback player @@ -200,14 +201,12 @@ export default function DynamicVideoPlayer({ }; }, [timeRange]); const { data: recordings } = useSWR( - previewOnly && onClick == undefined - ? null - : [`${camera}/recordings`, recordingParams], + [`${camera}/recordings`, recordingParams], { revalidateOnFocus: false }, ); useEffect(() => { - if (!controller || (!previewOnly && !recordings)) { + if (!controller || !recordings) { return; } @@ -224,26 +223,39 @@ export default function DynamicVideoPlayer({ return (
{ + setControls(true); + } + : undefined + } + onMouseOut={ + isDesktop + ? () => { + setControls(controlsOpen); + } + : undefined + } + onClick={ + isMobile + ? (e) => { + e.stopPropagation(); + setControls(!controls); + } + : undefined + } > -
+
{ @@ -260,6 +272,12 @@ export default function DynamicVideoPlayer({ /> )} +
{ setPreviewController(previewController); }} - onClick={onClick} />
); @@ -441,3 +458,111 @@ export class DynamicVideoController { ); } } + +type PlayerControlsProps = { + player: Player | null; + show: boolean; + controlsOpen: boolean; + setControlsOpen: (open: boolean) => void; +}; +function PlayerControls({ + player, + show, + controlsOpen, + setControlsOpen, +}: PlayerControlsProps) { + const playbackRates = useMemo(() => { + if (!player) { + return []; + } + + // @ts-expect-error player getter requires undefined + return player.playbackRates(undefined); + }, [player]); + + const onReplay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = player?.currentTime(); + + if (!player || !currentTime) { + return; + } + + player.currentTime(Math.max(0, currentTime - 10)); + }, + [player], + ); + + const onSkip = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const currentTime = player?.currentTime(); + + if (!player || !currentTime) { + return; + } + + player.currentTime(currentTime + 10); + }, + [player], + ); + + const onTogglePlay = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!player) { + return; + } + + if (player.paused()) { + player.play(); + } else { + player.pause(); + } + }, + [player], + ); + + if (!player || !show) { + return; + } + + return ( +
+ +
+ {player.paused() ? ( + + ) : ( + + )} +
+ + { + setControlsOpen(open); + }} + > + {`${player.playbackRate()}x`} + + player.playbackRate(parseInt(rate))} + > + {playbackRates.map((rate) => ( + + {rate}x + + ))} + + + +
+ ); +} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 12e869877..33fd8d1f1 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -161,7 +161,7 @@ export default function PreviewThumbnailPlayer({ return (
setIsHovered(true)} + onMouseOver={isMobile ? undefined : () => setIsHovered(true)} onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} onContextMenu={(e) => { e.preventDefault();