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,67 +315,103 @@ 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}
> >
<img <div
className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`} ref={rotateContainerRef}
src={currentHourFrame} className="size-full"
onLoad={() => { style={
if (changeoverTimeout) { rotate
clearTimeout(changeoverTimeout); ? { position: "relative" as const, overflow: "hidden" as const }
setChangeoverTimeout(undefined); : 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%" }
} }
previewRef.current?.load();
}}
/>
{isVisible && (
<video
ref={previewRef}
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
disablePictureInPicture
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
if (previewRef.current) {
setVideoSize([
previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime =
startTime - currentPreview.start;
}
}
}}
> >
{currentPreview != undefined && ( <img
<source className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`}
src={`${baseUrl}${currentPreview.src.substring(1)}`} style={
type={currentPreview.type} rotate
/> ? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
src={currentHourFrame}
onLoad={() => {
if (changeoverTimeout) {
clearTimeout(changeoverTimeout);
setChangeoverTimeout(undefined);
}
previewRef.current?.load();
}}
/>
{isVisible && (
<video
ref={previewRef}
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
style={
rotate
? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
disablePictureInPicture
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (firstLoad) {
setFirstLoad(false);
}
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
if (previewRef.current) {
setVideoSize([
previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime =
startTime - currentPreview.start;
}
}
}}
>
{currentPreview != undefined && (
<source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)}
</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}
> >
<img <div
ref={imgRef} ref={rotateContainerRef}
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`} className="size-full"
loading="lazy" style={
onLoad={onImageLoaded} 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
ref={imgRef}
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
style={
rotate
? { transform: "rotate(90deg)", transformOrigin: "center center" }
: undefined
}
loading="lazy"
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)
} }