Use new UI (#8983)

* fixup build

* swap frontends
This commit is contained in:
Blake Blackshear
2023-12-16 16:20:59 +00:00
parent a2c6f45454
commit bdebb99b5a
286 changed files with 20010 additions and 20007 deletions

View File

@@ -0,0 +1,175 @@
import WebRtcPlayer from "./WebRTCPlayer";
import { CameraConfig } from "@/types/frigateConfig";
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
import ActivityIndicator from "../ui/activity-indicator";
import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu";
import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import { usePersistence } from "@/hooks/use-persistence";
const emptyObject = Object.freeze({});
type LivePlayerProps = {
cameraConfig: CameraConfig;
liveMode: string;
};
type Options = { [key: string]: boolean };
export default function LivePlayer({
cameraConfig,
liveMode,
}: LivePlayerProps) {
const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence(
`${cameraConfig.name}-feed`,
emptyObject
);
const handleSetOption = useCallback(
(id: string, value: boolean) => {
console.log("Setting " + id + " to " + value);
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options, setOptions]
);
const searchParams = useMemo(
() =>
new URLSearchParams(
Object.keys(options).reduce((memo, key) => {
//@ts-ignore we know this is correct
memo.push([key, options[key] === true ? "1" : "0"]);
return memo;
}, [])
),
[options]
);
const handleToggleSettings = useCallback(() => {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
if (liveMode == "webrtc") {
return (
<div className="max-w-5xl">
<WebRtcPlayer camera={cameraConfig.live.stream_name} />
</div>
);
} else if (liveMode == "mse") {
return <div className="max-w-5xl">Not yet implemented</div>;
} else if (liveMode == "jsmpeg") {
return (
<div className={`max-w-[${cameraConfig.detect.width}px]`}>
Not Yet Implemented
</div>
);
} else if (liveMode == "debug") {
return (
<>
<AutoUpdatingCameraImage
camera={cameraConfig.name}
searchParams={searchParams}
/>
<Button onClick={handleToggleSettings} variant="link" size="sm">
<span className="w-5 h-5">
<LuSettings />
</span>{" "}
<span>{showSettings ? "Hide" : "Show"} Options</span>
</Button>
{showSettings ? (
<Card>
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent>
<DebugSettings
handleSetOption={handleSetOption}
options={options}
/>
</CardContent>
</Card>
) : null}
</>
);
} else {
<ActivityIndicator />;
}
}
type DebugSettingsProps = {
handleSetOption: (id: string, value: boolean) => void;
options: Options;
};
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div className="flex items-center space-x-2">
<Switch
id="bbox"
checked={options["bbox"]}
onCheckedChange={(isChecked) => {
handleSetOption("bbox", isChecked);
}}
/>
<Label htmlFor="bbox">Bounding Box</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="timestamp"
checked={options["timestamp"]}
onCheckedChange={(isChecked) => {
handleSetOption("timestamp", isChecked);
}}
/>
<Label htmlFor="timestamp">Timestamp</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="zones"
checked={options["zones"]}
onCheckedChange={(isChecked) => {
handleSetOption("zones", isChecked);
}}
/>
<Label htmlFor="zones">Zones</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="mask"
checked={options["mask"]}
onCheckedChange={(isChecked) => {
handleSetOption("mask", isChecked);
}}
/>
<Label htmlFor="mask">Mask</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="motion"
checked={options["motion"]}
onCheckedChange={(isChecked) => {
handleSetOption("motion", isChecked);
}}
/>
<Label htmlFor="motion">Motion</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="regions"
checked={options["regions"]}
onCheckedChange={(isChecked) => {
handleSetOption("regions", isChecked);
}}
/>
<Label htmlFor="regions">Regions</Label>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer";
import useSWR from "swr";
import { useCallback, useRef, useState } from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
type PreviewPlayerProps = {
camera: string;
relevantPreview?: Preview;
startTs: number;
eventId: string;
shouldAutoPlay: boolean;
};
type Preview = {
camera: string;
src: string;
type: string;
start: number;
end: number;
};
export default function PreviewThumbnailPlayer({
camera,
relevantPreview,
startTs,
eventId,
shouldAutoPlay,
}: PreviewPlayerProps) {
const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const [visible, setVisible] = useState(false);
const onPlayback = useCallback(
(isHovered: Boolean) => {
if (!relevantPreview || !playerRef.current) {
return;
}
if (isHovered) {
playerRef.current.play();
} else {
playerRef.current.pause();
playerRef.current.currentTime(startTs - relevantPreview.start);
}
},
[relevantPreview, startTs, playerRef]
);
const autoPlayObserver = useRef<IntersectionObserver | null>();
const preloadObserver = useRef<IntersectionObserver | null>();
const inViewRef = useCallback(
(node: HTMLElement | null) => {
if (!preloadObserver.current) {
try {
preloadObserver.current = new IntersectionObserver(
(entries) => {
const [{ isIntersecting }] = entries;
setVisible(isIntersecting);
},
{
threshold: 0,
root: document.getElementById("pageRoot"),
rootMargin: "10% 0px 25% 0px",
}
);
if (node) preloadObserver.current.observe(node);
} catch (e) {
// no op
}
}
if (shouldAutoPlay && !autoPlayObserver.current) {
try {
autoPlayObserver.current = new IntersectionObserver(
(entries) => {
const [{ isIntersecting }] = entries;
if (isIntersecting) {
onPlayback(true);
} else {
onPlayback(false);
}
},
{ threshold: 1.0 }
);
if (node) autoPlayObserver.current.observe(node);
} catch (e) {
// no op
}
}
},
[preloadObserver, autoPlayObserver, onPlayback]
);
let content;
if (!relevantPreview || !visible) {
if (isCurrentHour(startTs)) {
content = (
<img
className={`${getPreviewWidth(camera, config)}`}
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/>
);
}
content = (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
/>
);
} else {
content = (
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer
options={{
preload: "auto",
autoplay: false,
controls: false,
muted: true,
loadingSpinner: false,
sources: [
{
src: `${relevantPreview.src}`,
type: "video/mp4",
},
],
}}
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
player.playbackRate(8);
player.currentTime(startTs - relevantPreview.start);
}}
onDispose={() => {
playerRef.current = null;
}}
/>
</div>
);
}
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
{content}
</AspectRatio>
);
}
function isCurrentHour(timestamp: number) {
const now = new Date();
now.setMinutes(0, 0, 0);
return timestamp > now.getTime() / 1000;
}
function getPreviewWidth(camera: string, config: FrigateConfig) {
const detect = config.cameras[camera].detect;
if (detect.width / detect.height < 1.0) {
return "w-[120px]";
}
if (detect.width / detect.height < 1.4) {
return "w-[208px]";
}
return "w-full";
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, ReactElement } from "react";
import videojs from "video.js";
import "videojs-playlist";
import "video.js/dist/video-js.css";
import Player from "video.js/dist/types/player";
type VideoPlayerProps = {
children?: ReactElement | ReactElement[];
options?: {
[key: string]: any;
};
seekOptions?: {
forward?: number;
backward?: number;
};
remotePlayback?: boolean;
onReady?: (player: Player) => void;
onDispose?: () => void;
};
export default function VideoPlayer({
children,
options,
seekOptions = { forward: 30, backward: 10 },
remotePlayback = false,
onReady = (_) => {},
onDispose = () => {},
}: VideoPlayerProps) {
const videoRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<Player | null>(null);
useEffect(() => {
const defaultOptions = {
controls: true,
controlBar: {
skipButtons: seekOptions,
},
playbackRates: [0.5, 1, 2, 4, 8],
fluid: true,
};
if (!videojs.browser.IS_FIREFOX) {
defaultOptions.playbackRates.push(16);
}
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
const videoElement = document.createElement(
"video-js"
) as HTMLVideoElement;
videoElement.controls = true;
videoElement.playsInline = true;
videoElement.disableRemotePlayback = remotePlayback;
videoElement.classList.add("small-player");
videoElement.classList.add("video-js");
videoElement.classList.add("vjs-default-skin");
videoRef.current?.appendChild(videoElement);
const player = (playerRef.current = videojs(
videoElement,
{ ...defaultOptions, ...options },
() => {
onReady && onReady(player);
}
));
}
}, [options, videoRef]);
// Dispose the Video.js player when the functional component unmounts
useEffect(() => {
const player = playerRef.current;
return () => {
if (player && !player.isDisposed()) {
player.dispose();
playerRef.current = null;
onDispose();
}
};
}, [playerRef]);
return (
<div data-vjs-player>
<div ref={videoRef} />
{children}
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { baseUrl } from "@/api/baseUrl";
import { useCallback, useEffect, useRef } from "react";
type WebRtcPlayerProps = {
camera: string;
width?: number;
height?: number;
};
export default function WebRtcPlayer({
camera,
width,
height,
}: WebRtcPlayerProps) {
const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null);
const PeerConnection = useCallback(
async (media: string) => {
if (!videoRef.current) {
return;
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
const localTracks = [];
if (/camera|microphone/.test(media)) {
const tracks = await getMediaTracks("user", {
video: media.indexOf("camera") >= 0,
audio: media.indexOf("microphone") >= 0,
});
tracks.forEach((track) => {
pc.addTransceiver(track, { direction: "sendonly" });
if (track.kind === "video") localTracks.push(track);
});
}
if (media.indexOf("display") >= 0) {
const tracks = await getMediaTracks("display", {
video: true,
audio: media.indexOf("speaker") >= 0,
});
tracks.forEach((track) => {
pc.addTransceiver(track, { direction: "sendonly" });
if (track.kind === "video") localTracks.push(track);
});
}
if (/video|audio/.test(media)) {
const tracks = ["video", "audio"]
.filter((kind) => media.indexOf(kind) >= 0)
.map(
(kind) =>
pc.addTransceiver(kind, { direction: "recvonly" }).receiver.track
);
localTracks.push(...tracks);
}
videoRef.current.srcObject = new MediaStream(localTracks);
return pc;
},
[videoRef]
);
async function getMediaTracks(
media: string,
constraints: MediaStreamConstraints
) {
try {
const stream =
media === "user"
? await navigator.mediaDevices.getUserMedia(constraints)
: await navigator.mediaDevices.getDisplayMedia(constraints);
return stream.getTracks();
} catch (e) {
return [];
}
}
const connect = useCallback(
async (ws: WebSocket, aPc: Promise<RTCPeerConnection | undefined>) => {
if (!aPc) {
return;
}
pcRef.current = await aPc;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
const msg = {
type: "webrtc/candidate",
value: ev.candidate.candidate,
};
ws.send(JSON.stringify(msg));
});
pcRef.current
?.createOffer()
.then((offer) => pcRef.current?.setLocalDescription(offer))
.then(() => {
const msg = {
type: "webrtc/offer",
value: pcRef.current?.localDescription?.sdp,
};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.value,
});
}
});
},
[]
);
useEffect(() => {
if (!videoRef.current) {
return;
}
const url = `${baseUrl.replace(
/^http/,
"ws"
)}live/webrtc/api/ws?src=${camera}`;
const ws = new WebSocket(url);
const aPc = PeerConnection("video+audio");
connect(ws, aPc);
return () => {
if (pcRef.current) {
pcRef.current.close();
pcRef.current = undefined;
}
};
}, [camera, connect, PeerConnection, pcRef, videoRef]);
return (
<div>
<video
ref={videoRef}
autoPlay
playsInline
controls
muted
width={width}
height={height}
/>
</div>
);
}