mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Add birdseye live view (#10485)
* Add birdseye viewer and make it linkable * Add on click from main dashboard
This commit is contained in:
		
							parent
							
								
									657fab2787
								
							
						
					
					
						commit
						64763293a2
					
				@ -3,15 +3,20 @@ import { BirdseyeConfig } from "@/types/frigateConfig";
 | 
			
		||||
import ActivityIndicator from "../indicators/activity-indicator";
 | 
			
		||||
import JSMpegPlayer from "./JSMpegPlayer";
 | 
			
		||||
import MSEPlayer from "./MsePlayer";
 | 
			
		||||
import { LivePlayerMode } from "@/types/live";
 | 
			
		||||
 | 
			
		||||
type LivePlayerProps = {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  birdseyeConfig: BirdseyeConfig;
 | 
			
		||||
  liveMode: string;
 | 
			
		||||
  liveMode: LivePlayerMode;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function BirdseyeLivePlayer({
 | 
			
		||||
  className,
 | 
			
		||||
  birdseyeConfig,
 | 
			
		||||
  liveMode,
 | 
			
		||||
  onClick,
 | 
			
		||||
}: LivePlayerProps) {
 | 
			
		||||
  let player;
 | 
			
		||||
  if (liveMode == "webrtc") {
 | 
			
		||||
@ -45,7 +50,10 @@ export default function BirdseyeLivePlayer({
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={`relative flex justify-center w-full cursor-pointer`}>
 | 
			
		||||
    <div
 | 
			
		||||
      className={`relative flex justify-center w-full cursor-pointer ${className ?? ""}`}
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
 | 
			
		||||
      <div className="absolute bottom-0 inset-x-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
 | 
			
		||||
      <div className="size-full">{player}</div>
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import {
 | 
			
		||||
  usePersistedOverlayState,
 | 
			
		||||
} from "@/hooks/use-overlay-state";
 | 
			
		||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
 | 
			
		||||
import LiveCameraView from "@/views/live/LiveCameraView";
 | 
			
		||||
import LiveDashboardView from "@/views/live/LiveDashboardView";
 | 
			
		||||
import { useMemo } from "react";
 | 
			
		||||
@ -47,6 +48,10 @@ function Live() {
 | 
			
		||||
    [cameras, selectedCameraName],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (selectedCameraName == "birdseye") {
 | 
			
		||||
    return <LiveBirdseyeView />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (selectedCamera) {
 | 
			
		||||
    return <LiveCameraView camera={selectedCamera} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										189
									
								
								web/src/views/live/LiveBirdseyeView.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								web/src/views/live/LiveBirdseyeView.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,189 @@
 | 
			
		||||
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
 | 
			
		||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
 | 
			
		||||
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { TooltipProvider } from "@/components/ui/tooltip";
 | 
			
		||||
import { useResizeObserver } from "@/hooks/resize-observer";
 | 
			
		||||
import { FrigateConfig } from "@/types/frigateConfig";
 | 
			
		||||
import { useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  isDesktop,
 | 
			
		||||
  isMobile,
 | 
			
		||||
  isSafari,
 | 
			
		||||
  useMobileOrientation,
 | 
			
		||||
} from "react-device-detect";
 | 
			
		||||
import { FaCompress, FaExpand } from "react-icons/fa";
 | 
			
		||||
import { IoMdArrowBack } from "react-icons/io";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
 | 
			
		||||
import useSWR from "swr";
 | 
			
		||||
 | 
			
		||||
export default function LiveBirdseyeView() {
 | 
			
		||||
  const { data: config } = useSWR<FrigateConfig>("config");
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const { isPortrait } = useMobileOrientation();
 | 
			
		||||
  const mainRef = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
  const [{ width: windowWidth, height: windowHeight }] =
 | 
			
		||||
    useResizeObserver(window);
 | 
			
		||||
 | 
			
		||||
  // fullscreen state
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (mainRef.current == null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const listener = () => {
 | 
			
		||||
      setFullscreen(document.fullscreenElement != null);
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("fullscreenchange", listener);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("fullscreenchange", listener);
 | 
			
		||||
    };
 | 
			
		||||
  }, [mainRef]);
 | 
			
		||||
 | 
			
		||||
  // playback state
 | 
			
		||||
 | 
			
		||||
  const [fullscreen, setFullscreen] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const cameraAspectRatio = useMemo(() => {
 | 
			
		||||
    if (!config) {
 | 
			
		||||
      return 16 / 9;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return config.birdseye.width / config.birdseye.height;
 | 
			
		||||
  }, [config]);
 | 
			
		||||
 | 
			
		||||
  const growClassName = useMemo(() => {
 | 
			
		||||
    if (isMobile) {
 | 
			
		||||
      if (isPortrait) {
 | 
			
		||||
        return "absolute left-2 right-2 top-[50%] -translate-y-[50%]";
 | 
			
		||||
      } else {
 | 
			
		||||
        if (cameraAspectRatio > 16 / 9) {
 | 
			
		||||
          return "absolute left-0 top-[50%] -translate-y-[50%]";
 | 
			
		||||
        } else {
 | 
			
		||||
          return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (fullscreen) {
 | 
			
		||||
      if (cameraAspectRatio > 16 / 9) {
 | 
			
		||||
        return "absolute inset-x-2 top-[50%] -translate-y-[50%]";
 | 
			
		||||
      } else {
 | 
			
		||||
        return "absolute inset-y-2 left-[50%] -translate-x-[50%]";
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]";
 | 
			
		||||
    }
 | 
			
		||||
  }, [cameraAspectRatio, fullscreen, isPortrait]);
 | 
			
		||||
 | 
			
		||||
  const preferredLiveMode = useMemo(() => {
 | 
			
		||||
    if (!config || !config.birdseye.restream) {
 | 
			
		||||
      return "jsmpeg";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (isSafari) {
 | 
			
		||||
      return "webrtc";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return "mse";
 | 
			
		||||
  }, [config]);
 | 
			
		||||
 | 
			
		||||
  const windowAspectRatio = useMemo(() => {
 | 
			
		||||
    return windowWidth / windowHeight;
 | 
			
		||||
  }, [windowWidth, windowHeight]);
 | 
			
		||||
 | 
			
		||||
  const aspectRatio = useMemo<number>(() => {
 | 
			
		||||
    if (isMobile || fullscreen) {
 | 
			
		||||
      return cameraAspectRatio;
 | 
			
		||||
    } else {
 | 
			
		||||
      return windowAspectRatio < cameraAspectRatio
 | 
			
		||||
        ? windowAspectRatio - 0.05
 | 
			
		||||
        : cameraAspectRatio - 0.03;
 | 
			
		||||
    }
 | 
			
		||||
  }, [cameraAspectRatio, windowAspectRatio, fullscreen]);
 | 
			
		||||
 | 
			
		||||
  if (!config) {
 | 
			
		||||
    return <ActivityIndicator />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TransformWrapper minScale={1.0}>
 | 
			
		||||
      <div
 | 
			
		||||
        ref={mainRef}
 | 
			
		||||
        className={
 | 
			
		||||
          fullscreen
 | 
			
		||||
            ? `fixed inset-0 bg-black z-30`
 | 
			
		||||
            : `size-full flex flex-col ${isMobile ? "landscape:flex-row" : ""}`
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <div
 | 
			
		||||
          className={
 | 
			
		||||
            fullscreen
 | 
			
		||||
              ? `absolute right-32 top-1 z-40 ${isMobile ? "landscape:left-2 landscape:right-auto landscape:bottom-1 landscape:top-auto" : ""}`
 | 
			
		||||
              : `w-full h-12 flex flex-row items-center justify-between ${isMobile ? "landscape:w-min landscape:h-full landscape:flex-col" : ""}`
 | 
			
		||||
          }
 | 
			
		||||
        >
 | 
			
		||||
          {!fullscreen ? (
 | 
			
		||||
            <Button
 | 
			
		||||
              className={`rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
 | 
			
		||||
              size={isMobile ? "icon" : "default"}
 | 
			
		||||
              onClick={() => navigate(-1)}
 | 
			
		||||
            >
 | 
			
		||||
              <IoMdArrowBack className="size-5 lg:mr-[10px]" />
 | 
			
		||||
              {isDesktop && "Back"}
 | 
			
		||||
            </Button>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <div />
 | 
			
		||||
          )}
 | 
			
		||||
          <TooltipProvider>
 | 
			
		||||
            <div
 | 
			
		||||
              className={`flex flex-row items-center gap-2 mr-1 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
 | 
			
		||||
            >
 | 
			
		||||
              <CameraFeatureToggle
 | 
			
		||||
                className="p-2 md:p-0"
 | 
			
		||||
                variant={fullscreen ? "overlay" : "primary"}
 | 
			
		||||
                Icon={fullscreen ? FaCompress : FaExpand}
 | 
			
		||||
                isActive={fullscreen}
 | 
			
		||||
                title={fullscreen ? "Close" : "Fullscreen"}
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  if (fullscreen) {
 | 
			
		||||
                    document.exitFullscreen();
 | 
			
		||||
                  } else {
 | 
			
		||||
                    mainRef.current?.requestFullscreen();
 | 
			
		||||
                  }
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </TooltipProvider>
 | 
			
		||||
        </div>
 | 
			
		||||
        <TransformComponent
 | 
			
		||||
          wrapperStyle={{
 | 
			
		||||
            width: "100%",
 | 
			
		||||
            height: "100%",
 | 
			
		||||
          }}
 | 
			
		||||
          contentStyle={{
 | 
			
		||||
            position: "relative",
 | 
			
		||||
            width: "100%",
 | 
			
		||||
            height: "100%",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <div
 | 
			
		||||
            className={growClassName}
 | 
			
		||||
            style={{
 | 
			
		||||
              aspectRatio: aspectRatio,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <BirdseyeLivePlayer
 | 
			
		||||
              className="h-full"
 | 
			
		||||
              birdseyeConfig={config.birdseye}
 | 
			
		||||
              liveMode={preferredLiveMode}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </TransformComponent>
 | 
			
		||||
      </div>
 | 
			
		||||
    </TransformWrapper>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -134,6 +134,7 @@ export default function LiveDashboardView({
 | 
			
		||||
          <BirdseyeLivePlayer
 | 
			
		||||
            birdseyeConfig={birdseyeConfig}
 | 
			
		||||
            liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
 | 
			
		||||
            onClick={() => onSelectCamera("birdseye")}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {cameras.map((camera) => {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user