diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 71b544300..31e908e24 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -13,6 +13,7 @@ import DetailStream from "@/components/timeline/DetailStream"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useOverlayState } from "@/hooks/use-overlay-state"; +import { useUserPersistence } from "@/hooks/use-user-persistence"; import { useResizeObserver } from "@/hooks/resize-observer"; import { ExportMode } from "@/types/filter"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -29,6 +30,7 @@ import { import { getChunkedTimeDay } from "@/utils/timelineUtil"; import { MutableRefObject, + PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, @@ -79,6 +81,9 @@ import { } from "@/components/overlay/chip/GenAISummaryChip"; const DATA_REFRESH_TIME = 600000; // 10 minutes +const MOBILE_VIDEO_SPLIT_DEFAULT = 0.5; +const MOBILE_VIDEO_SPLIT_MIN = 0.35; +const MOBILE_VIDEO_SPLIT_MAX = 0.8; type RecordingViewProps = { startCamera: string; @@ -384,6 +389,89 @@ export function RecordingView({ const { fullscreen, toggleFullscreen, supportsFullScreen } = useFullscreen(mainLayoutRef); + // mobile portrait split between video and timeline + + const [mobileVideoSplit, setMobileVideoSplit] = useUserPersistence( + "recordingMobileVideoSplit", + MOBILE_VIDEO_SPLIT_DEFAULT, + ); + const mobileVideoSplitSafe = useMemo( + () => + Math.min( + MOBILE_VIDEO_SPLIT_MAX, + Math.max( + MOBILE_VIDEO_SPLIT_MIN, + mobileVideoSplit ?? MOBILE_VIDEO_SPLIT_DEFAULT, + ), + ), + [mobileVideoSplit], + ); + const [isDraggingMobileSplit, setIsDraggingMobileSplit] = useState(false); + const [{ width: mainLayoutWidth, height: mainLayoutHeight }] = + useResizeObserver(mainLayoutRef); + const isMobilePortraitStacked = useMemo( + () => !isDesktop && mainLayoutHeight > mainLayoutWidth, + [mainLayoutHeight, mainLayoutWidth], + ); + + const updateMobileSplitFromClientY = useCallback( + (clientY: number) => { + if (!mainLayoutRef.current || !isMobilePortraitStacked) { + return; + } + + const rect = mainLayoutRef.current.getBoundingClientRect(); + if (rect.height <= 0) { + return; + } + + const split = (clientY - rect.top) / rect.height; + const clampedSplit = Math.min( + MOBILE_VIDEO_SPLIT_MAX, + Math.max(MOBILE_VIDEO_SPLIT_MIN, split), + ); + setMobileVideoSplit(clampedSplit); + }, + [isMobilePortraitStacked, setMobileVideoSplit], + ); + + const onMobileSplitPointerDown = useCallback( + (e: ReactPointerEvent) => { + if (!isMobilePortraitStacked) { + return; + } + + e.preventDefault(); + setIsDraggingMobileSplit(true); + updateMobileSplitFromClientY(e.clientY); + }, + [isMobilePortraitStacked, updateMobileSplitFromClientY], + ); + + useEffect(() => { + if (!isDraggingMobileSplit) { + return; + } + + const onPointerMove = (e: PointerEvent) => { + updateMobileSplitFromClientY(e.clientY); + }; + + const onPointerUp = () => { + setIsDraggingMobileSplit(false); + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onPointerUp); + + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onPointerUp); + }; + }, [isDraggingMobileSplit, updateMobileSplitFromClientY]); + // layout const getCameraAspect = useCallback( @@ -778,8 +866,18 @@ export function RecordingView({ "flex flex-1 flex-wrap overflow-hidden", isDesktop ? "min-w-0 px-4" - : "portrait:max-h-[50dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto", + : cn( + "portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto", + isMobilePortraitStacked + ? "portrait:flex-none" + : "portrait:max-h-[50dvh]", + ), )} + style={ + isMobilePortraitStacked + ? { height: `${Math.round(mobileVideoSplitSafe * 100)}%` } + : undefined + } >
+ {isMobilePortraitStacked && !fullscreen && ( + + )}