forked from Github/frigate
175
web/src/components/player/LivePlayer.tsx
Normal file
175
web/src/components/player/LivePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
web/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
178
web/src/components/player/PreviewThumbnailPlayer.tsx
Normal 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";
|
||||
}
|
||||
89
web/src/components/player/VideoPlayer.tsx
Normal file
89
web/src/components/player/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
web/src/components/player/WebRTCPlayer.tsx
Normal file
161
web/src/components/player/WebRTCPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user