Merge pull request #94 from ibs0d/claude/add-camera-rotation-support-Lg2l9

Add ui.rotate support to PreviewPlayer (scrubbing preview in recordings)
This commit is contained in:
ibs0d 2026-03-21 21:50:12 +11:00 committed by GitHub
commit fc5e59ec54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 188 additions and 58 deletions

View File

@ -33,6 +33,7 @@ type PreviewPlayerProps = {
isScrubbing: boolean; isScrubbing: boolean;
forceAspect?: number; forceAspect?: number;
isVisible?: boolean; isVisible?: boolean;
rotate?: boolean;
onControllerReady: (controller: PreviewController) => void; onControllerReady: (controller: PreviewController) => void;
onClick?: () => void; onClick?: () => void;
}; };
@ -45,6 +46,7 @@ export default function PreviewPlayer({
startTime, startTime,
isScrubbing, isScrubbing,
isVisible = true, isVisible = true,
rotate,
onControllerReady, onControllerReady,
onClick, onClick,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
@ -69,6 +71,7 @@ export default function PreviewPlayer({
isScrubbing={isScrubbing} isScrubbing={isScrubbing}
isVisible={isVisible} isVisible={isVisible}
currentHourFrame={currentHourFrame} currentHourFrame={currentHourFrame}
rotate={rotate}
onControllerReady={onControllerReady} onControllerReady={onControllerReady}
onClick={onClick} onClick={onClick}
setCurrentHourFrame={setCurrentHourFrame} setCurrentHourFrame={setCurrentHourFrame}
@ -83,6 +86,7 @@ export default function PreviewPlayer({
camera={camera} camera={camera}
timeRange={timeRange} timeRange={timeRange}
startTime={startTime} startTime={startTime}
rotate={rotate}
onControllerReady={onControllerReady} onControllerReady={onControllerReady}
onClick={onClick} onClick={onClick}
setCurrentHourFrame={setCurrentHourFrame} setCurrentHourFrame={setCurrentHourFrame}
@ -127,6 +131,7 @@ type PreviewVideoPlayerProps = {
isScrubbing: boolean; isScrubbing: boolean;
isVisible: boolean; isVisible: boolean;
currentHourFrame?: string; currentHourFrame?: string;
rotate?: boolean;
onControllerReady: (controller: PreviewVideoController) => void; onControllerReady: (controller: PreviewVideoController) => void;
onClick?: () => void; onClick?: () => void;
setCurrentHourFrame: (src: string | undefined) => void; setCurrentHourFrame: (src: string | undefined) => void;
@ -142,6 +147,7 @@ function PreviewVideoPlayer({
isScrubbing, isScrubbing,
isVisible, isVisible,
currentHourFrame, currentHourFrame,
rotate,
onControllerReady, onControllerReady,
onClick, onClick,
setCurrentHourFrame, setCurrentHourFrame,
@ -183,6 +189,33 @@ function PreviewVideoPlayer({
controller.scrubbing = isScrubbing; controller.scrubbing = isScrubbing;
}, [controller, isScrubbing]); }, [controller, isScrubbing]);
// rotation support
const rotateContainerRef = useRef<HTMLDivElement>(null);
const [rotateContainerSize, setRotateContainerSize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
if (!rotate) return;
const container = rotateContainerRef.current;
if (!container) return;
const updateSize = () => {
setRotateContainerSize({
width: container.clientWidth,
height: container.clientHeight,
});
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [rotate]);
// initial state // initial state
const [firstLoad, setFirstLoad] = useState(true); const [firstLoad, setFirstLoad] = useState(true);
@ -282,14 +315,43 @@ function PreviewVideoPlayer({
ref={visibilityRef} ref={visibilityRef}
className={cn( className={cn(
"relative flex w-full justify-center overflow-hidden rounded-lg bg-black md:rounded-2xl", "relative flex w-full justify-center overflow-hidden rounded-lg bg-black md:rounded-2xl",
rotate && "h-full",
onClick && "cursor-pointer", onClick && "cursor-pointer",
className, className,
)} )}
data-camera={camera} data-camera={camera}
onClick={onClick} onClick={onClick}
>
<div
ref={rotateContainerRef}
className="size-full"
style={
rotate
? { position: "relative" as const, overflow: "hidden" as const }
: undefined
}
>
<div
style={
rotate
? {
position: "absolute" as const,
top: "50%",
left: "50%",
width: rotateContainerSize.height || "100%",
height: rotateContainerSize.width || "100%",
transform: "translate(-50%, -50%)",
}
: { width: "100%", height: "100%" }
}
> >
<img <img
className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`} className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`}
style={
rotate
? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
src={currentHourFrame} src={currentHourFrame}
onLoad={() => { onLoad={() => {
if (changeoverTimeout) { if (changeoverTimeout) {
@ -304,6 +366,11 @@ function PreviewVideoPlayer({
<video <video
ref={previewRef} ref={previewRef}
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`} className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
style={
rotate
? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
preload="auto" preload="auto"
autoPlay autoPlay
playsInline playsInline
@ -343,6 +410,8 @@ function PreviewVideoPlayer({
)} )}
</video> </video>
)} )}
</div>
</div>
{cameraPreviews && !currentPreview && ( {cameraPreviews && !currentPreview && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl"> <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { camera: cameraName })} {t("noPreviewFoundFor", { camera: cameraName })}
@ -452,6 +521,7 @@ type PreviewFramesPlayerProps = {
camera: string; camera: string;
timeRange: TimeRange; timeRange: TimeRange;
startTime?: number; startTime?: number;
rotate?: boolean;
onControllerReady: (controller: PreviewController) => void; onControllerReady: (controller: PreviewController) => void;
onClick?: () => void; onClick?: () => void;
setCurrentHourFrame: (src: string) => void; setCurrentHourFrame: (src: string) => void;
@ -461,6 +531,7 @@ function PreviewFramesPlayer({
camera, camera,
timeRange, timeRange,
startTime, startTime,
rotate,
setCurrentHourFrame, setCurrentHourFrame,
onControllerReady, onControllerReady,
onClick, onClick,
@ -504,6 +575,33 @@ function PreviewFramesPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [imgRef, frameTimes, imgRef.current]); }, [imgRef, frameTimes, imgRef.current]);
// rotation support
const rotateContainerRef = useRef<HTMLDivElement>(null);
const [rotateContainerSize, setRotateContainerSize] = useState({
width: 0,
height: 0,
});
useEffect(() => {
if (!rotate) return;
const container = rotateContainerRef.current;
if (!container) return;
const updateSize = () => {
setRotateContainerSize({
width: container.clientWidth,
height: container.clientHeight,
});
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [rotate]);
// initial state // initial state
const [firstLoad, setFirstLoad] = useState(true); const [firstLoad, setFirstLoad] = useState(true);
@ -555,17 +653,48 @@ function PreviewFramesPlayer({
<div <div
className={cn( className={cn(
"relative flex w-full justify-center", "relative flex w-full justify-center",
rotate && "h-full",
className, className,
onClick && "cursor-pointer", onClick && "cursor-pointer",
)} )}
onClick={onClick} onClick={onClick}
>
<div
ref={rotateContainerRef}
className="size-full"
style={
rotate
? { position: "relative" as const, overflow: "hidden" as const }
: undefined
}
>
<div
style={
rotate
? {
position: "absolute" as const,
top: "50%",
left: "50%",
width: rotateContainerSize.height || "100%",
height: rotateContainerSize.width || "100%",
transform: "translate(-50%, -50%)",
}
: { width: "100%", height: "100%" }
}
> >
<img <img
ref={imgRef} ref={imgRef}
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`} className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
style={
rotate
? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
loading="lazy" loading="lazy"
onLoad={onImageLoaded} onLoad={onImageLoaded}
/> />
</div>
</div>
{previewFrames?.length === 0 && ( {previewFrames?.length === 0 && (
<div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl"> <div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { cameraName: cameraName })} {t("noPreviewFoundFor", { cameraName: cameraName })}

View File

@ -337,6 +337,7 @@ export default function DynamicVideoPlayer({
cameraPreviews={cameraPreviews} cameraPreviews={cameraPreviews}
startTime={startTimestamp} startTime={startTimestamp}
isScrubbing={isScrubbing} isScrubbing={isScrubbing}
rotate={rotate}
onControllerReady={(previewController) => onControllerReady={(previewController) =>
setPreviewController(previewController) setPreviewController(previewController)
} }