diff --git a/web/src/components/player/BirdseyeLivePlayer.tsx b/web/src/components/player/BirdseyeLivePlayer.tsx
new file mode 100644
index 000000000..a43e2266a
--- /dev/null
+++ b/web/src/components/player/BirdseyeLivePlayer.tsx
@@ -0,0 +1,36 @@
+import WebRtcPlayer from "./WebRTCPlayer";
+import { BirdseyeConfig } from "@/types/frigateConfig";
+import ActivityIndicator from "../ui/activity-indicator";
+import JSMpegPlayer from "./JSMpegPlayer";
+
+type LivePlayerProps = {
+ birdseyeConfig: BirdseyeConfig;
+ liveMode: string;
+};
+
+export default function BirdseyeLivePlayer({
+ birdseyeConfig,
+ liveMode,
+}: LivePlayerProps) {
+ if (liveMode == "webrtc") {
+ return (
+
+
+
+ );
+ } else if (liveMode == "mse") {
+ return Not yet implemented
;
+ } else if (liveMode == "jsmpeg") {
+ return (
+
+
+
+ );
+ } else {
+ ;
+ }
+}
diff --git a/web/src/components/player/JSMpegPlayer.tsx b/web/src/components/player/JSMpegPlayer.tsx
new file mode 100644
index 000000000..2abf23049
--- /dev/null
+++ b/web/src/components/player/JSMpegPlayer.tsx
@@ -0,0 +1,94 @@
+import { baseUrl } from "@/api/baseUrl";
+import { useResizeObserver } from "@/hooks/resize-observer";
+// @ts-ignore we know this doesn't have types
+import JSMpeg from "@cycjimmy/jsmpeg-player";
+import { useEffect, useMemo, useRef } from "react";
+
+type JSMpegPlayerProps = {
+ camera: string;
+ width: number;
+ height: number;
+};
+
+export default function JSMpegPlayer({
+ camera,
+ width,
+ height,
+}: JSMpegPlayerProps) {
+ const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
+ const playerRef = useRef(null);
+ const containerRef = useRef(null);
+ const [{ width: containerWidth }] = useResizeObserver(containerRef);
+
+ // Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
+ // https://github.com/blakeblackshear/frigate/issues/1657
+ let scrollBarWidth = 0;
+ if (window.innerWidth && document.body.offsetWidth) {
+ scrollBarWidth = window.innerWidth - document.body.offsetWidth;
+ }
+ const availableWidth = scrollBarWidth
+ ? containerWidth + scrollBarWidth
+ : containerWidth;
+ const aspectRatio = width / height;
+
+ const scaledHeight = useMemo(() => {
+ const scaledHeight = Math.floor(availableWidth / aspectRatio);
+ const finalHeight = Math.min(scaledHeight, height);
+
+ if (finalHeight > 0) {
+ return finalHeight;
+ }
+
+ return 100;
+ }, [availableWidth, aspectRatio, height]);
+ const scaledWidth = useMemo(
+ () => Math.ceil(scaledHeight * aspectRatio - scrollBarWidth),
+ [scaledHeight, aspectRatio, scrollBarWidth]
+ );
+
+ useEffect(() => {
+ if (!playerRef.current) {
+ return;
+ }
+
+ console.log("player ref exists and creating video");
+ const video = new JSMpeg.VideoElement(
+ playerRef.current,
+ url,
+ {},
+ { protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 }
+ );
+
+ const fullscreen = () => {
+ if (video.els.canvas.webkitRequestFullScreen) {
+ video.els.canvas.webkitRequestFullScreen();
+ } else {
+ video.els.canvas.mozRequestFullScreen();
+ }
+ };
+
+ video.els.canvas.addEventListener("click", fullscreen);
+
+ return () => {
+ if (playerRef.current) {
+ try {
+ video.destroy();
+ } catch (e) {}
+ playerRef.current = null;
+ }
+ };
+ }, [url]);
+
+ return (
+
+ );
+}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx
index a6e61c03c..a850d575a 100644
--- a/web/src/components/player/LivePlayer.tsx
+++ b/web/src/components/player/LivePlayer.tsx
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import { usePersistence } from "@/hooks/use-persistence";
+import JSMpegPlayer from "./JSMpegPlayer";
const emptyObject = Object.freeze({});
@@ -66,7 +67,11 @@ export default function LivePlayer({
} else if (liveMode == "jsmpeg") {
return (
- Not Yet Implemented
+
);
} else if (liveMode == "debug") {
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx
index 264bb2b93..bc7d35fe3 100644
--- a/web/src/pages/Live.tsx
+++ b/web/src/pages/Live.tsx
@@ -1,3 +1,4 @@
+import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
@@ -21,18 +22,19 @@ function Live() {
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState(
- openedCamera ?? "Select A Camera"
+ openedCamera ?? (config?.birdseye.enabled ? "birdseye" : "Select A Camera")
);
const cameraConfig = useMemo(() => {
- return config?.cameras[camera];
+ return camera == "birdseye" ? undefined : config?.cameras[camera];
}, [camera, config]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
- return Object.values(config.cameras)
- .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
+ return Object.values(config.cameras).sort(
+ (aConf, bConf) => aConf.ui.order - bConf.ui.order
+ );
}, [config]);
const restreamEnabled = useMemo(() => {
return (
@@ -56,7 +58,7 @@ function Live() {
}, [cameraConfig, restreamEnabled]);
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(
`${camera}-source`,
- defaultLiveMode
+ camera == "birdseye" ? "jsmpeg" : defaultLiveMode
);
return (
@@ -74,7 +76,7 @@ function Live() {
Select A Camera
- {(sortedCameras).map((item) => (
+ {sortedCameras.map((item) => (
+ {config && camera == "birdseye" && sourceIsLoaded && (
+
+ )}
{cameraConfig && sourceIsLoaded && (