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", "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"

View File

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

View File

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

View File

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

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