mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57: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",
|
"documentTitle": "Review - Frigate",
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"documentTitle": "Recordings - Frigate"
|
"documentTitle": "Recordings - Frigate",
|
||||||
|
"resizeSplit": "Resize video and timeline"
|
||||||
},
|
},
|
||||||
"calendarFilter": {
|
"calendarFilter": {
|
||||||
"last24Hours": "Last 24 Hours"
|
"last24Hours": "Last 24 Hours"
|
||||||
|
|||||||
@ -38,8 +38,10 @@ export interface HlsSource {
|
|||||||
|
|
||||||
type HlsVideoPlayerProps = {
|
type HlsVideoPlayerProps = {
|
||||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||||
|
videoClassName?: string;
|
||||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
showControls?: boolean;
|
||||||
currentSource: HlsSource;
|
currentSource: HlsSource;
|
||||||
hotKeys: boolean;
|
hotKeys: boolean;
|
||||||
supportsFullscreen: boolean;
|
supportsFullscreen: boolean;
|
||||||
@ -63,8 +65,10 @@ type HlsVideoPlayerProps = {
|
|||||||
|
|
||||||
export default function HlsVideoPlayer({
|
export default function HlsVideoPlayer({
|
||||||
videoRef,
|
videoRef,
|
||||||
|
videoClassName,
|
||||||
containerRef,
|
containerRef,
|
||||||
visible,
|
visible,
|
||||||
|
showControls = visible,
|
||||||
currentSource,
|
currentSource,
|
||||||
hotKeys,
|
hotKeys,
|
||||||
supportsFullscreen,
|
supportsFullscreen,
|
||||||
@ -286,7 +290,7 @@ export default function HlsVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
show={visible && (controls || controlsOpen)}
|
show={showControls && (controls || controlsOpen)}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
features={{
|
features={{
|
||||||
@ -390,7 +394,12 @@ export default function HlsVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
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"
|
preload="auto"
|
||||||
autoPlay
|
autoPlay
|
||||||
controls={!frigateControls}
|
controls={!frigateControls}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { isFirefox } from "react-device-detect";
|
|||||||
*/
|
*/
|
||||||
type DynamicVideoPlayerProps = {
|
type DynamicVideoPlayerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
videoClassName?: string;
|
||||||
camera: string;
|
camera: string;
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
cameraPreviews: Preview[];
|
cameraPreviews: Preview[];
|
||||||
@ -51,6 +52,7 @@ type DynamicVideoPlayerProps = {
|
|||||||
};
|
};
|
||||||
export default function DynamicVideoPlayer({
|
export default function DynamicVideoPlayer({
|
||||||
className,
|
className,
|
||||||
|
videoClassName,
|
||||||
camera,
|
camera,
|
||||||
timeRange,
|
timeRange,
|
||||||
cameraPreviews,
|
cameraPreviews,
|
||||||
@ -279,13 +281,33 @@ export default function DynamicVideoPlayer({
|
|||||||
[onClipEnded, controller, recordings],
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{source && (
|
{source && (
|
||||||
<HlsVideoPlayer
|
<HlsVideoPlayer
|
||||||
videoRef={playerRef}
|
videoRef={playerRef}
|
||||||
|
videoClassName={videoClassName}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
visible={!(isScrubbing || isLoading)}
|
visible={true}
|
||||||
|
showControls={!isScrubbing && !isLoading}
|
||||||
currentSource={source}
|
currentSource={source}
|
||||||
hotKeys={hotKeys}
|
hotKeys={hotKeys}
|
||||||
supportsFullscreen={supportsFullscreen}
|
supportsFullscreen={supportsFullscreen}
|
||||||
@ -321,23 +343,15 @@ export default function DynamicVideoPlayer({
|
|||||||
isDetailMode={isDetailMode}
|
isDetailMode={isDetailMode}
|
||||||
camera={contextCamera || camera}
|
camera={contextCamera || camera}
|
||||||
currentTimeOverride={currentTime}
|
currentTimeOverride={currentTime}
|
||||||
transformedOverlay={transformedOverlay}
|
transformedOverlay={
|
||||||
|
<>
|
||||||
|
{transformedOverlay}
|
||||||
|
{previewPlayer}
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PreviewPlayer
|
{!source && previewPlayer}
|
||||||
className={cn(
|
|
||||||
className,
|
|
||||||
isScrubbing || isLoading ? "visible" : "hidden",
|
|
||||||
)}
|
|
||||||
camera={camera}
|
|
||||||
timeRange={timeRange}
|
|
||||||
cameraPreviews={cameraPreviews}
|
|
||||||
startTime={startTimestamp}
|
|
||||||
isScrubbing={isScrubbing}
|
|
||||||
onControllerReady={(previewController) =>
|
|
||||||
setPreviewController(previewController)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
|
{!isScrubbing && (isLoading || isBuffering) && !noRecording && (
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -77,6 +77,7 @@ import {
|
|||||||
GenAISummaryDialog,
|
GenAISummaryDialog,
|
||||||
GenAISummaryChip,
|
GenAISummaryChip,
|
||||||
} from "@/components/overlay/chip/GenAISummaryChip";
|
} from "@/components/overlay/chip/GenAISummaryChip";
|
||||||
|
import { useMobileVideoSplit } from "./useMobileVideoSplit";
|
||||||
|
|
||||||
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
||||||
|
|
||||||
@ -370,6 +371,16 @@ export function RecordingView({
|
|||||||
|
|
||||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||||
useFullscreen(mainLayoutRef);
|
useFullscreen(mainLayoutRef);
|
||||||
|
const {
|
||||||
|
cameraSectionStyle,
|
||||||
|
isDraggingMobileSplit,
|
||||||
|
isMobilePortraitStacked,
|
||||||
|
onHandlePointerDown,
|
||||||
|
usePortraitSplitLayout,
|
||||||
|
} = useMobileVideoSplit({
|
||||||
|
fullscreen,
|
||||||
|
mainLayoutRef,
|
||||||
|
});
|
||||||
|
|
||||||
// layout
|
// layout
|
||||||
|
|
||||||
@ -765,8 +776,14 @@ 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={cameraSectionStyle}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -786,17 +803,21 @@ export function RecordingView({
|
|||||||
useHeightBased
|
useHeightBased
|
||||||
? "h-full"
|
? "h-full"
|
||||||
: "w-full"
|
: "w-full"
|
||||||
: cn(
|
: usePortraitSplitLayout
|
||||||
"flex-shrink-0 portrait:w-full landscape:h-full",
|
? "size-full"
|
||||||
mainCameraAspect == "wide"
|
: cn(
|
||||||
? "aspect-wide"
|
"flex-shrink-0 portrait:w-full landscape:h-full",
|
||||||
: mainCameraAspect == "tall"
|
mainCameraAspect == "wide"
|
||||||
? "aspect-tall portrait:h-full"
|
? "aspect-wide"
|
||||||
: "aspect-video",
|
: mainCameraAspect == "tall"
|
||||||
),
|
? "aspect-tall portrait:h-full"
|
||||||
|
: "aspect-video",
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: getCameraAspect(mainCamera),
|
aspectRatio: usePortraitSplitLayout
|
||||||
|
? undefined
|
||||||
|
: getCameraAspect(mainCamera),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(isDesktop || isTablet) && (
|
{(isDesktop || isTablet) && (
|
||||||
@ -810,6 +831,9 @@ export function RecordingView({
|
|||||||
|
|
||||||
<DynamicVideoPlayer
|
<DynamicVideoPlayer
|
||||||
className={grow}
|
className={grow}
|
||||||
|
videoClassName={
|
||||||
|
usePortraitSplitLayout ? "object-contain" : undefined
|
||||||
|
}
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
@ -898,6 +922,19 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Timeline
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
mainCamera={mainCamera}
|
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