mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
History mobile: add draggable video/timeline split
(cherry picked from commit 26166d36881a644fa8d833394f208a28f4a72804)
This commit is contained in:
parent
997f15ab9c
commit
f6bde23dc5
@ -13,6 +13,7 @@ import DetailStream from "@/components/timeline/DetailStream";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { ExportMode } from "@/types/filter";
|
import { ExportMode } from "@/types/filter";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -29,6 +30,7 @@ import {
|
|||||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||||
import {
|
import {
|
||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@ -79,6 +81,9 @@ import {
|
|||||||
} from "@/components/overlay/chip/GenAISummaryChip";
|
} from "@/components/overlay/chip/GenAISummaryChip";
|
||||||
|
|
||||||
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
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 = {
|
type RecordingViewProps = {
|
||||||
startCamera: string;
|
startCamera: string;
|
||||||
@ -384,6 +389,89 @@ export function RecordingView({
|
|||||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||||
useFullscreen(mainLayoutRef);
|
useFullscreen(mainLayoutRef);
|
||||||
|
|
||||||
|
// mobile portrait split between video and timeline
|
||||||
|
|
||||||
|
const [mobileVideoSplit, setMobileVideoSplit] = useUserPersistence<number>(
|
||||||
|
"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<HTMLButtonElement>) => {
|
||||||
|
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
|
// layout
|
||||||
|
|
||||||
const getCameraAspect = useCallback(
|
const getCameraAspect = useCallback(
|
||||||
@ -778,8 +866,18 @@ export function RecordingView({
|
|||||||
"flex flex-1 flex-wrap overflow-hidden",
|
"flex flex-1 flex-wrap overflow-hidden",
|
||||||
isDesktop
|
isDesktop
|
||||||
? "min-w-0 px-4"
|
? "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
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -923,6 +1021,19 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobilePortraitStacked && !fullscreen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Resize video and timeline"
|
||||||
|
onPointerDown={onMobileSplitPointerDown}
|
||||||
|
className={cn(
|
||||||
|
"relative z-30 mx-auto h-4 w-full flex-shrink-0 touch-none",
|
||||||
|
isDraggingMobileSplit ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="absolute inset-x-1/2 top-1/2 h-1 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/60" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Timeline
|
<Timeline
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
mainCamera={mainCamera}
|
mainCamera={mainCamera}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user