History: isolate zoom and pan UX improvements

This commit is contained in:
nrlcode 2026-03-25 10:24:29 -07:00
parent 80c4ce2b5d
commit 9eb0a89de0
5 changed files with 223 additions and 29 deletions

View File

@ -45,7 +45,8 @@
},
"documentTitle": "Review - Frigate",
"recordings": {
"documentTitle": "Recordings - Frigate"
"documentTitle": "Recordings - Frigate",
"resizeSplit": "Resize video and timeline"
},
"calendarFilter": {
"last24Hours": "Last 24 Hours"

View File

@ -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}

View File

@ -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" />
)}

View File

@ -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}

View 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,
};
}