mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 14:47:40 +03:00
History: isolate zoom and pan UX improvements
This commit is contained in:
parent
80c4ce2b5d
commit
9eb0a89de0
@ -45,7 +45,8 @@
|
||||
},
|
||||
"documentTitle": "Review - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Recordings - Frigate"
|
||||
"documentTitle": "Recordings - Frigate",
|
||||
"resizeSplit": "Resize video and timeline"
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Last 24 Hours"
|
||||
|
||||
@ -38,8 +38,10 @@ export interface HlsSource {
|
||||
|
||||
type HlsVideoPlayerProps = {
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
videoClassName?: string;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
visible: boolean;
|
||||
showControls?: boolean;
|
||||
currentSource: HlsSource;
|
||||
hotKeys: boolean;
|
||||
supportsFullscreen: boolean;
|
||||
@ -63,8 +65,10 @@ type HlsVideoPlayerProps = {
|
||||
|
||||
export default function HlsVideoPlayer({
|
||||
videoRef,
|
||||
videoClassName,
|
||||
containerRef,
|
||||
visible,
|
||||
showControls = visible,
|
||||
currentSource,
|
||||
hotKeys,
|
||||
supportsFullscreen,
|
||||
@ -286,7 +290,7 @@ export default function HlsVideoPlayer({
|
||||
)}
|
||||
video={videoRef.current}
|
||||
isPlaying={isPlaying}
|
||||
show={visible && (controls || controlsOpen)}
|
||||
show={showControls && (controls || controlsOpen)}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
features={{
|
||||
@ -390,7 +394,12 @@ export default function HlsVideoPlayer({
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"} cursor-pointer`}
|
||||
className={cn(
|
||||
"size-full rounded-lg bg-black md:rounded-2xl",
|
||||
loadedMetadata ? "" : "invisible",
|
||||
"cursor-pointer",
|
||||
videoClassName,
|
||||
)}
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={!frigateControls}
|
||||
|
||||
@ -32,6 +32,7 @@ import { isFirefox } from "react-device-detect";
|
||||
*/
|
||||
type DynamicVideoPlayerProps = {
|
||||
className?: string;
|
||||
videoClassName?: string;
|
||||
camera: string;
|
||||
timeRange: TimeRange;
|
||||
cameraPreviews: Preview[];
|
||||
@ -51,6 +52,7 @@ type DynamicVideoPlayerProps = {
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
className,
|
||||
videoClassName,
|
||||
camera,
|
||||
timeRange,
|
||||
cameraPreviews,
|
||||
@ -279,13 +281,33 @@ export default function DynamicVideoPlayer({
|
||||
[onClipEnded, controller, recordings],
|
||||
);
|
||||
|
||||
const showPreview = isScrubbing || isLoading;
|
||||
const previewPlayer = (
|
||||
<PreviewPlayer
|
||||
className={cn(
|
||||
source ? "pointer-events-none absolute inset-0 z-20" : className,
|
||||
showPreview ? "visible" : "invisible",
|
||||
)}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
cameraPreviews={cameraPreviews}
|
||||
startTime={startTimestamp}
|
||||
isScrubbing={isScrubbing}
|
||||
onControllerReady={(previewController) =>
|
||||
setPreviewController(previewController)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{source && (
|
||||
<HlsVideoPlayer
|
||||
videoRef={playerRef}
|
||||
videoClassName={videoClassName}
|
||||
containerRef={containerRef}
|
||||
visible={!(isScrubbing || isLoading)}
|
||||
visible={true}
|
||||
showControls={!isScrubbing && !isLoading}
|
||||
currentSource={source}
|
||||
hotKeys={hotKeys}
|
||||
supportsFullscreen={supportsFullscreen}
|
||||
@ -321,23 +343,15 @@ export default function DynamicVideoPlayer({
|
||||
isDetailMode={isDetailMode}
|
||||
camera={contextCamera || camera}
|
||||
currentTimeOverride={currentTime}
|
||||
transformedOverlay={transformedOverlay}
|
||||
transformedOverlay={
|
||||
<>
|
||||
{transformedOverlay}
|
||||
{previewPlayer}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PreviewPlayer
|
||||
className={cn(
|
||||
className,
|
||||
isScrubbing || isLoading ? "visible" : "hidden",
|
||||
)}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
cameraPreviews={cameraPreviews}
|
||||
startTime={startTimestamp}
|
||||
isScrubbing={isScrubbing}
|
||||
onControllerReady={(previewController) =>
|
||||
setPreviewController(previewController)
|
||||
}
|
||||
/>
|
||||
{!source && previewPlayer}
|
||||
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
|
||||
@ -77,6 +77,7 @@ import {
|
||||
GenAISummaryDialog,
|
||||
GenAISummaryChip,
|
||||
} from "@/components/overlay/chip/GenAISummaryChip";
|
||||
import { useMobileVideoSplit } from "./useMobileVideoSplit";
|
||||
|
||||
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
||||
|
||||
@ -370,6 +371,16 @@ export function RecordingView({
|
||||
|
||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||
useFullscreen(mainLayoutRef);
|
||||
const {
|
||||
cameraSectionStyle,
|
||||
isDraggingMobileSplit,
|
||||
isMobilePortraitStacked,
|
||||
onHandlePointerDown,
|
||||
usePortraitSplitLayout,
|
||||
} = useMobileVideoSplit({
|
||||
fullscreen,
|
||||
mainLayoutRef,
|
||||
});
|
||||
|
||||
// layout
|
||||
|
||||
@ -765,8 +776,14 @@ 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={cameraSectionStyle}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@ -786,17 +803,21 @@ export function RecordingView({
|
||||
useHeightBased
|
||||
? "h-full"
|
||||
: "w-full"
|
||||
: cn(
|
||||
"flex-shrink-0 portrait:w-full landscape:h-full",
|
||||
mainCameraAspect == "wide"
|
||||
? "aspect-wide"
|
||||
: mainCameraAspect == "tall"
|
||||
? "aspect-tall portrait:h-full"
|
||||
: "aspect-video",
|
||||
),
|
||||
: usePortraitSplitLayout
|
||||
? "size-full"
|
||||
: cn(
|
||||
"flex-shrink-0 portrait:w-full landscape:h-full",
|
||||
mainCameraAspect == "wide"
|
||||
? "aspect-wide"
|
||||
: mainCameraAspect == "tall"
|
||||
? "aspect-tall portrait:h-full"
|
||||
: "aspect-video",
|
||||
),
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: getCameraAspect(mainCamera),
|
||||
aspectRatio: usePortraitSplitLayout
|
||||
? undefined
|
||||
: getCameraAspect(mainCamera),
|
||||
}}
|
||||
>
|
||||
{(isDesktop || isTablet) && (
|
||||
@ -810,6 +831,9 @@ export function RecordingView({
|
||||
|
||||
<DynamicVideoPlayer
|
||||
className={grow}
|
||||
videoClassName={
|
||||
usePortraitSplitLayout ? "object-contain" : undefined
|
||||
}
|
||||
camera={mainCamera}
|
||||
timeRange={currentTimeRange}
|
||||
cameraPreviews={allPreviews ?? []}
|
||||
@ -898,6 +922,19 @@ export function RecordingView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isMobilePortraitStacked && !fullscreen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("recordings.resizeSplit")}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
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
|
||||
contentRef={contentRef}
|
||||
mainCamera={mainCamera}
|
||||
|
||||
133
web/src/views/recording/useMobileVideoSplit.ts
Normal file
133
web/src/views/recording/useMobileVideoSplit.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
CSSProperties,
|
||||
MutableRefObject,
|
||||
PointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
|
||||
const MOBILE_VIDEO_SPLIT_DEFAULT = 0.5;
|
||||
const MOBILE_VIDEO_SPLIT_MIN = 0.35;
|
||||
const MOBILE_VIDEO_SPLIT_MAX = 0.8;
|
||||
|
||||
type UseMobileVideoSplitProps = {
|
||||
fullscreen: boolean;
|
||||
mainLayoutRef: MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
type UseMobileVideoSplitReturn = {
|
||||
cameraSectionStyle: CSSProperties | undefined;
|
||||
isDraggingMobileSplit: boolean;
|
||||
isMobilePortraitStacked: boolean;
|
||||
onHandlePointerDown: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
usePortraitSplitLayout: boolean;
|
||||
};
|
||||
|
||||
export function useMobileVideoSplit({
|
||||
fullscreen,
|
||||
mainLayoutRef,
|
||||
}: UseMobileVideoSplitProps): UseMobileVideoSplitReturn {
|
||||
const [mobileVideoSplit, setMobileVideoSplit] = useUserPersistence<number>(
|
||||
"recordingMobileVideoSplit",
|
||||
MOBILE_VIDEO_SPLIT_DEFAULT,
|
||||
);
|
||||
const [isDraggingMobileSplit, setIsDraggingMobileSplit] = useState(false);
|
||||
const [{ width: mainLayoutWidth, height: mainLayoutHeight }] =
|
||||
useResizeObserver(mainLayoutRef);
|
||||
|
||||
const isMobilePortraitStacked = useMemo(
|
||||
() => !isDesktop && mainLayoutHeight > mainLayoutWidth,
|
||||
[mainLayoutHeight, mainLayoutWidth],
|
||||
);
|
||||
const usePortraitSplitLayout = isMobilePortraitStacked && !fullscreen;
|
||||
const mobileVideoSplitSafe = useMemo(
|
||||
() =>
|
||||
Math.min(
|
||||
MOBILE_VIDEO_SPLIT_MAX,
|
||||
Math.max(
|
||||
MOBILE_VIDEO_SPLIT_MIN,
|
||||
mobileVideoSplit ?? MOBILE_VIDEO_SPLIT_DEFAULT,
|
||||
),
|
||||
),
|
||||
[mobileVideoSplit],
|
||||
);
|
||||
|
||||
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, mainLayoutRef, setMobileVideoSplit],
|
||||
);
|
||||
|
||||
const onHandlePointerDown = useCallback(
|
||||
(event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isMobilePortraitStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setIsDraggingMobileSplit(true);
|
||||
updateMobileSplitFromClientY(event.clientY);
|
||||
},
|
||||
[isMobilePortraitStacked, updateMobileSplitFromClientY],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingMobileSplit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPointerMove = (event: globalThis.PointerEvent) => {
|
||||
updateMobileSplitFromClientY(event.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]);
|
||||
|
||||
const cameraSectionStyle = useMemo(
|
||||
() =>
|
||||
isMobilePortraitStacked
|
||||
? { height: `${Math.round(mobileVideoSplitSafe * 100)}%` }
|
||||
: undefined,
|
||||
[isMobilePortraitStacked, mobileVideoSplitSafe],
|
||||
);
|
||||
|
||||
return {
|
||||
cameraSectionStyle,
|
||||
isDraggingMobileSplit,
|
||||
isMobilePortraitStacked,
|
||||
onHandlePointerDown,
|
||||
usePortraitSplitLayout,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user