Implement recordings fullscreen and rework recordings layout size calculation (#11318)

* Implement fullscreen button

* wrap items on mobile

* control based on width

* refresh

* Implement basic fullscreen

* Fix scrolling

* Add observer to detect of row overflows

* Use cn to simplify classnames

* dynamically respond to layout sizing

* Simplify listener

* Simplify layout

* Handle tall browser
This commit is contained in:
Nicolas Mowen 2024-05-09 15:06:29 -06:00 committed by GitHub
parent 021ffb2437
commit 8b344cea81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 180 additions and 19 deletions

View File

@ -6,7 +6,7 @@ import {
useState, useState,
} from "react"; } from "react";
import Hls from "hls.js"; import Hls from "hls.js";
import { isAndroid, isDesktop, isMobile } from "react-device-detect"; import { isAndroid, isDesktop, isIOS, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import VideoControls from "./VideoControls"; import VideoControls from "./VideoControls";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
@ -28,24 +28,28 @@ type HlsVideoPlayerProps = {
visible: boolean; visible: boolean;
currentSource: string; currentSource: string;
hotKeys: boolean; hotKeys: boolean;
fullscreen: boolean;
onClipEnded?: () => void; onClipEnded?: () => void;
onPlayerLoaded?: () => void; onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void; onTimeUpdate?: (time: number) => void;
onPlaying?: () => void; onPlaying?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
setFullscreen?: (full: boolean) => void;
}; };
export default function HlsVideoPlayer({ export default function HlsVideoPlayer({
videoRef, videoRef,
visible, visible,
currentSource, currentSource,
hotKeys, hotKeys,
fullscreen,
onClipEnded, onClipEnded,
onPlayerLoaded, onPlayerLoaded,
onTimeUpdate, onTimeUpdate,
onPlaying, onPlaying,
setFullResolution, setFullResolution,
onUploadFrame, onUploadFrame,
setFullscreen,
}: HlsVideoPlayerProps) { }: HlsVideoPlayerProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -153,6 +157,7 @@ export default function HlsVideoPlayer({
seek: true, seek: true,
playbackRate: true, playbackRate: true,
plusUpload: config?.plus?.enabled == true, plusUpload: config?.plus?.enabled == true,
fullscreen: !isIOS,
}} }}
setControlsOpen={setControlsOpen} setControlsOpen={setControlsOpen}
setMuted={setMuted} setMuted={setMuted}
@ -196,6 +201,8 @@ export default function HlsVideoPlayer({
} }
} }
}} }}
fullscreen={fullscreen}
setFullscreen={setFullscreen}
/> />
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{

View File

@ -30,12 +30,14 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FaCompress, FaExpand } from "react-icons/fa";
type VideoControls = { type VideoControls = {
volume?: boolean; volume?: boolean;
seek?: boolean; seek?: boolean;
playbackRate?: boolean; playbackRate?: boolean;
plusUpload?: boolean; plusUpload?: boolean;
fullscreen?: boolean;
}; };
const CONTROLS_DEFAULT: VideoControls = { const CONTROLS_DEFAULT: VideoControls = {
@ -43,6 +45,7 @@ const CONTROLS_DEFAULT: VideoControls = {
seek: true, seek: true,
playbackRate: true, playbackRate: true,
plusUpload: false, plusUpload: false,
fullscreen: false,
}; };
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
@ -57,12 +60,14 @@ type VideoControlsProps = {
playbackRates?: number[]; playbackRates?: number[];
playbackRate: number; playbackRate: number;
hotKeys?: boolean; hotKeys?: boolean;
fullscreen?: boolean;
setControlsOpen?: (open: boolean) => void; setControlsOpen?: (open: boolean) => void;
setMuted?: (muted: boolean) => void; setMuted?: (muted: boolean) => void;
onPlayPause: (play: boolean) => void; onPlayPause: (play: boolean) => void;
onSeek: (diff: number) => void; onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void; onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void; onUploadFrame?: () => void;
setFullscreen?: (full: boolean) => void;
}; };
export default function VideoControls({ export default function VideoControls({
className, className,
@ -75,12 +80,14 @@ export default function VideoControls({
playbackRates = PLAYBACK_RATE_DEFAULT, playbackRates = PLAYBACK_RATE_DEFAULT,
playbackRate, playbackRate,
hotKeys = true, hotKeys = true,
fullscreen,
setControlsOpen, setControlsOpen,
setMuted, setMuted,
onPlayPause, onPlayPause,
onSeek, onSeek,
onSetPlaybackRate, onSetPlaybackRate,
onUploadFrame, onUploadFrame,
setFullscreen,
}: VideoControlsProps) { }: VideoControlsProps) {
const onReplay = useCallback( const onReplay = useCallback(
(e: React.MouseEvent<SVGElement>) => { (e: React.MouseEvent<SVGElement>) => {
@ -163,7 +170,7 @@ export default function VideoControls({
return ( return (
<div <div
className={cn( className={cn(
"px-4 py-2 flex justify-between items-center gap-8 text-primary z-50 bg-background/60 rounded-lg", "w-[96%] sm:w-auto px-4 py-2 flex flex-wrap sm:flex-nowrap justify-between items-center gap-4 sm:gap-8 text-primary z-50 bg-background/60 rounded-lg",
className, className,
)} )}
> >
@ -248,6 +255,14 @@ export default function VideoControls({
onUploadFrame={onUploadFrame} onUploadFrame={onUploadFrame}
/> />
)} )}
{features.fullscreen && setFullscreen && (
<div
className="cursor-pointer"
onClick={() => setFullscreen(!fullscreen)}
>
{fullscreen ? <FaCompress /> : <FaExpand />}
</div>
)}
</div> </div>
); );
} }

View File

@ -24,10 +24,12 @@ type DynamicVideoPlayerProps = {
startTimestamp?: number; startTimestamp?: number;
isScrubbing: boolean; isScrubbing: boolean;
hotKeys: boolean; hotKeys: boolean;
fullscreen: boolean;
onControllerReady: (controller: DynamicVideoController) => void; onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void; onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void; onClipEnded?: () => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
setFullscreen: (full: boolean) => void;
}; };
export default function DynamicVideoPlayer({ export default function DynamicVideoPlayer({
className, className,
@ -37,10 +39,12 @@ export default function DynamicVideoPlayer({
startTimestamp, startTimestamp,
isScrubbing, isScrubbing,
hotKeys, hotKeys,
fullscreen,
onControllerReady, onControllerReady,
onTimestampUpdate, onTimestampUpdate,
onClipEnded, onClipEnded,
setFullResolution, setFullResolution,
setFullscreen,
}: DynamicVideoPlayerProps) { }: DynamicVideoPlayerProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -184,6 +188,7 @@ export default function DynamicVideoPlayer({
visible={!(isScrubbing || isLoading)} visible={!(isScrubbing || isLoading)}
currentSource={source} currentSource={source}
hotKeys={hotKeys} hotKeys={hotKeys}
fullscreen={fullscreen}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded} onPlayerLoaded={onPlayerLoaded}
onClipEnded={onClipEnded} onClipEnded={onClipEnded}
@ -201,6 +206,7 @@ export default function DynamicVideoPlayer({
}} }}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus} onUploadFrame={onUploadFrameToPlus}
setFullscreen={setFullscreen}
/> />
<PreviewPlayer <PreviewPlayer
className={cn( className={cn(

View File

@ -43,5 +43,19 @@ export function useResizeObserver(...refs: RefType[]) {
}; };
}, [refs, resizeObserver]); }, [refs, resizeObserver]);
return dimensions; if (dimensions.length == refs.length) {
return dimensions;
} else {
const items = [...dimensions];
for (let i = dimensions.length; i < refs.length; i++) {
items.push({
width: 0,
height: 0,
x: -Infinity,
y: -Infinity,
});
}
return items;
}
} }

View File

@ -997,6 +997,7 @@ function MotionReview({
volume: false, volume: false,
seek: true, seek: true,
playbackRate: true, playbackRate: true,
fullscreen: false,
}} }}
isPlaying={playing} isPlaying={playing}
show={!scrubbing || controlsOpen} show={!scrubbing || controlsOpen}

View File

@ -43,6 +43,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils";
const SEGMENT_DURATION = 30; const SEGMENT_DURATION = 30;
@ -76,6 +78,9 @@ export function RecordingView({
const [mainCamera, setMainCamera] = useState(startCamera); const [mainCamera, setMainCamera] = useState(startCamera);
const mainControllerRef = useRef<DynamicVideoController | null>(null); const mainControllerRef = useRef<DynamicVideoController | null>(null);
const mainLayoutRef = useRef<HTMLDivElement | null>(null);
const cameraLayoutRef = useRef<HTMLDivElement | null>(null);
const previewRowRef = useRef<HTMLDivElement | null>(null);
const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const previewRefs = useRef<{ [camera: string]: PreviewController }>({});
const [playbackStart, setPlaybackStart] = useState(startTime); const [playbackStart, setPlaybackStart] = useState(startTime);
@ -208,7 +213,36 @@ export function RecordingView({
[currentTime], [currentTime],
); );
// motion timeline data // fullscreen
const [fullscreen, setFullscreen] = useState(false);
const onToggleFullscreen = useCallback(
(full: boolean) => {
if (full) {
mainLayoutRef.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
},
[mainLayoutRef],
);
useEffect(() => {
if (mainLayoutRef.current == null) {
return;
}
const fsListener = () => {
setFullscreen(document.fullscreenElement != null);
};
document.addEventListener("fullscreenchange", fsListener);
return () => {
document.removeEventListener("fullscreenchange", fsListener);
};
}, [mainLayoutRef]);
// layout
const getCameraAspect = useCallback( const getCameraAspect = useCallback(
(cam: string) => { (cam: string) => {
@ -259,20 +293,67 @@ export function RecordingView({
} }
}, [mainCameraAspect]); }, [mainCameraAspect]);
const [{ width: mainWidth, height: mainHeight }] =
useResizeObserver(cameraLayoutRef);
const mainCameraStyle = useMemo(() => {
if (isMobile || mainCameraAspect != "normal" || !config) {
return undefined;
}
const camera = config.cameras[mainCamera];
if (!camera) {
return undefined;
}
const aspect = camera.detect.width / camera.detect.height;
if (!aspect) {
return undefined;
}
const availableHeight = mainHeight - 112;
let percent;
if (mainWidth / availableHeight < aspect) {
percent = 100;
} else {
const availableWidth = aspect * availableHeight;
percent =
(mainWidth < availableWidth
? mainWidth / availableWidth
: availableWidth / mainWidth) * 100;
}
return {
width: `${Math.round(percent)}%`,
};
}, [config, mainCameraAspect, mainWidth, mainHeight, mainCamera]);
const previewRowOverflows = useMemo(() => {
if (!previewRowRef.current) {
return false;
}
return (
previewRowRef.current.scrollWidth > previewRowRef.current.clientWidth ||
previewRowRef.current.scrollHeight > previewRowRef.current.clientHeight
);
// we only want to update when the scroll size changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewRowRef.current?.scrollWidth, previewRowRef.current?.scrollHeight]);
return ( return (
<div ref={contentRef} className="size-full pt-2 flex flex-col"> <div ref={contentRef} className="size-full pt-2 flex flex-col">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<div <div className="w-full h-11 mb-2 px-2 relative flex items-center justify-between">
className={`w-full h-11 mb-2 px-2 relative flex items-center justify-between`}
>
{isMobile && ( {isMobile && (
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" /> <Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
)} )}
<div <div className={cn("flex items-center gap-2")}>
className={`flex items-center gap-2 ${isMobile ? "landscape:flex-col" : ""}`}
>
<Button <Button
className={`flex items-center gap-2.5 rounded-lg`} className="flex items-center gap-2.5 rounded-lg"
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
@ -372,20 +453,46 @@ export function RecordingView({
</div> </div>
<div <div
className={`h-full flex justify-center overflow-hidden ${isDesktop ? "" : "flex-col landscape:flex-row gap-2"}`} ref={mainLayoutRef}
className={cn(
"h-full flex justify-center overflow-hidden",
isDesktop ? "" : "flex-col landscape:flex-row gap-2",
)}
> >
<div className={`${isDesktop ? "w-[80%]" : ""} flex flex-1 flex-wrap`}> <div
ref={cameraLayoutRef}
className={cn("flex flex-1 flex-wrap", isDesktop ? "w-[80%]" : "")}
>
<div <div
className={`size-full flex items-center ${mainCameraAspect == "tall" ? "flex-row justify-evenly" : "flex-col justify-center gap-2"}`} className={cn(
"size-full flex items-center",
mainCameraAspect == "tall"
? "flex-row justify-evenly"
: "flex-col justify-center gap-2",
)}
> >
<div <div
key={mainCamera} key={mainCamera}
className={`relative ${ className={cn(
"relative",
isDesktop isDesktop
? `${mainCameraAspect == "tall" ? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]" : mainCameraAspect == "wide" ? "w-full" : "w-[78%]"} px-4 flex justify-center` ? cn(
: `portrait:w-full pt-2 ${mainCameraAspect == "wide" ? "landscape:w-full aspect-wide" : "landscape:h-[94%] aspect-video"}` "px-4 flex justify-center",
}`} mainCameraAspect == "tall"
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]"
: mainCameraAspect == "wide"
? "w-full"
: "",
)
: cn(
"portrait:w-full pt-2",
mainCameraAspect == "wide"
? "landscape:w-full aspect-wide"
: "landscape:h-[94%] aspect-video",
),
)}
style={{ style={{
width: mainCameraStyle ? mainCameraStyle.width : undefined,
aspectRatio: isDesktop aspectRatio: isDesktop
? mainCameraAspect == "tall" ? mainCameraAspect == "tall"
? getCameraAspect(mainCamera) ? getCameraAspect(mainCamera)
@ -400,6 +507,7 @@ export function RecordingView({
cameraPreviews={allPreviews ?? []} cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart} startTimestamp={playbackStart}
hotKeys={exportMode != "select"} hotKeys={exportMode != "select"}
fullscreen={fullscreen}
onTimestampUpdate={(timestamp) => { onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp); setPlayerTime(timestamp);
setCurrentTime(timestamp); setCurrentTime(timestamp);
@ -413,12 +521,21 @@ export function RecordingView({
}} }}
isScrubbing={scrubbing || exportMode == "timeline"} isScrubbing={scrubbing || exportMode == "timeline"}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
setFullscreen={onToggleFullscreen}
/> />
</div> </div>
{isDesktop && ( {isDesktop && (
<div <div
className={`flex gap-2 ${mainCameraAspect == "tall" ? "h-full w-[12%] flex-col justify-center overflow-y-auto" : "w-full h-[14%] justify-center items-center overflow-x-auto"} `} ref={previewRowRef}
className={cn(
"flex gap-2 overflow-auto",
mainCameraAspect == "tall"
? "h-full w-48 flex-col"
: `w-full h-28`,
previewRowOverflows ? "" : "justify-center items-center",
)}
> >
<div className="w-2" />
{allCameras.map((cam) => { {allCameras.map((cam) => {
if (cam == mainCamera) { if (cam == mainCamera) {
return; return;
@ -450,6 +567,7 @@ export function RecordingView({
</div> </div>
); );
})} })}
<div className="w-2" />
</div> </div>
)} )}
</div> </div>