Streamline live view (#9772)

* Break out live page

* Improving layouts and add chip component

* Improve default camera player sizing

* Improve live updating

* Cleanup and fit figma

* Use fixed height

* Masonry layout

* Fix stuff

* Don't force heights

* Adjust scaling

* Cleanup

* remove sidebar (#9731)

* remove sidebar

* keep sidebar on mobile for now and add icons

* Fix revalidation

* Cleanup

* Cleanup width

* Add chips for activity on cameras

* Remove dashboard from header

* Use Inter font (#9735)

* Show still image when no activity is occurring

* remove unused search params

* add playing check for webrtc

* Don't use grid at all for single column

* Fix height on mobile

* a few style updates to better match figma (#9745)

* Remove active objects when they become stationary

* Move to sidebar only and make settings separate component

* Fix layout

* Animate visibility of chips

* Sidebar is full screen

* Fix tall aspect ratio cameras

* Fix complicated aspect logic

* remove

* Adjust thumbnail aspect and add text

* margin on single column layout

* Smaller event thumb text

* Simplify basic image view

* Only show the red dot when camera is recording

* Improve typing for camera toggles

* animate chips with react-transition-group (#9763)

* don't flash when going to still image

* revalidate

* tooltips and active tracking outline (#9766)

* tooltips

* fix tooltip provider and add active tracking outline

* remove unused icon

* remove figma comment

* Get live mode working for jsmpeg

* add small gradient below timeago on event thumbnails (#9767)

* Create live mode hook and make sure jsmpeg can be used

* Enforce env var

* Use print

* Remove unstable

* Add tooltips to thumbnails

* Put back vite

* Format

* Update web/src/components/player/JSMpegPlayer.tsx

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
Nicolas Mowen
2024-02-10 05:30:53 -07:00
committed by GitHub
parent f6a4c2a7b3
commit 64988c9be0
33 changed files with 1111 additions and 972 deletions

View File

@@ -123,7 +123,7 @@ function ConfigEditor() {
}
return (
<div className="absolute top-28 bottom-16 right-0 left-0 md:left-24 lg:left-60">
<div className="absolute top-2 bottom-16 right-0 left-0 md:left-12">
<div className="lg:flex justify-between mr-1">
<Heading as="h2">Config</Heading>
<div>

View File

@@ -1,176 +0,0 @@
import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator";
import {
useAudioState,
useDetectState,
useRecordingsState,
useSnapshotsState,
} from "@/api/ws";
import useSWR from "swr";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AiOutlinePicture } from "react-icons/ai";
import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
import MiniEventCard from "@/components/card/MiniEventCard";
import { Event as FrigateEvent } from "@/types/event";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DynamicCameraImage from "@/components/camera/DynamicCameraImage";
export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config");
const now = new Date();
now.setMinutes(now.getMinutes() - 30, 0, 0);
const recentTimestamp = now.getTime() / 1000;
const { data: events, mutate: updateEvents } = useSWR<FrigateEvent[]>([
"events",
{ limit: 10, after: recentTimestamp },
]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
return (
<>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />}
{config && (
<div>
{events && events.length > 0 && (
<>
<Heading as="h4">Recent Events</Heading>
<ScrollArea>
<div className="flex">
{events.map((event) => {
return (
<MiniEventCard
key={event.id}
event={event}
onUpdate={() => updateEvents()}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
)}
<Heading as="h4">Cameras</Heading>
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{sortedCameras.map((camera) => {
return <Camera key={camera.name} camera={camera} />;
})}
</div>
</div>
)}
</>
);
}
function Camera({ camera }: { camera: CameraConfig }) {
const { payload: detectValue, send: sendDetect } = useDetectState(
camera.name
);
const { payload: recordValue, send: sendRecord } = useRecordingsState(
camera.name
);
const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState(
camera.name
);
const { payload: audioValue, send: sendAudio } = useAudioState(camera.name);
return (
<>
<Card>
<a href={`/live/${camera.name}`}>
<DynamicCameraImage aspect={16 / 9} camera={camera} />
<div className="flex justify-between items-center">
<div className="text-lg capitalize p-2">
{camera.name.replaceAll("_", " ")}
</div>
<div>
<Button
variant="ghost"
size="sm"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendDetect(detectValue == "ON" ? "OFF" : "ON");
}}
>
<FaWalking />
</Button>
<Button
variant="ghost"
size="sm"
className={
camera.record.enabled_in_config
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-danger"
}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
camera.record.enabled_in_config
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
: {};
}}
>
<TbMovie />
</Button>
<Button
variant="ghost"
size="sm"
className={`${
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendSnapshot(detectValue == "ON" ? "OFF" : "ON");
}}
>
<AiOutlinePicture />
</Button>
{camera.audio.enabled_in_config && (
<Button
variant="ghost"
size="sm"
className={`${
audioValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
sendAudio(audioValue == "ON" ? "OFF" : "ON");
}}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</>
);
}
export default Dashboard;

View File

@@ -1,161 +1,101 @@
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import { EventThumbnail } from "@/components/image/EventThumbnail";
import LivePlayer from "@/components/player/LivePlayer";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Event as FrigateEvent } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import axios from "axios";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
function Live() {
const { data: config } = useSWR<FrigateConfig>("config");
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>(
openedCamera ?? (config?.birdseye.enabled ? "birdseye" : "Select A Camera")
// recent events
const { data: allEvents, mutate: updateEvents } = useSWR<FrigateEvent[]>(
["events", { limit: 10 }],
{ refreshInterval: 60000 }
);
const cameraConfig = useMemo(() => {
return camera == "birdseye" ? undefined : config?.cameras[camera];
}, [camera, config]);
const sortedCameras = useMemo(() => {
const events = useMemo(() => {
if (!allEvents) {
return [];
}
const date = new Date();
date.setHours(date.getHours() - 1);
const cutoff = date.getTime() / 1000;
return allEvents.filter((event) => event.start_time > cutoff);
}, [allEvents]);
const onFavorite = useCallback(async (e: Event, event: FrigateEvent) => {
e.stopPropagation();
let response;
if (!event.retain_indefinitely) {
response = await axios.post(`events/${event.id}/retain`);
} else {
response = await axios.delete(`events/${event.id}/retain`);
}
if (response.status === 200) {
updateEvents();
}
}, []);
// camera live views
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras).sort(
(aConf, bConf) => aConf.ui.order - bConf.ui.order
);
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const restreamEnabled = useMemo(() => {
if (!config) {
return false;
}
if (camera == "birdseye") {
return config.birdseye.restream;
}
return (
cameraConfig &&
Object.keys(config.go2rtc.streams || {}).includes(
cameraConfig.live.stream_name
)
);
}, [config, cameraConfig]);
const defaultLiveMode = useMemo(() => {
if (cameraConfig) {
if (restreamEnabled) {
return cameraConfig.ui.live_mode || config?.ui.live_mode;
}
return "jsmpeg";
}
return undefined;
}, [cameraConfig, restreamEnabled]);
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(
`${camera}-source`,
camera == "birdseye" ? "jsmpeg" : defaultLiveMode
);
return (
<div className=" w-full">
<div className="flex justify-between">
<Heading as="h2">Live</Heading>
<div className="flex">
<div className="mx-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{camera?.replaceAll("_", " ") || "Select A Camera"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={camera}
onValueChange={setCamera}
>
{config?.birdseye.enabled && (
<DropdownMenuRadioItem value="birdseye">
Birdseye
</DropdownMenuRadioItem>
)}
{sortedCameras.map((item) => (
<DropdownMenuRadioItem
className="capitalize"
key={item.name}
value={item.name}
>
{item.name.replaceAll("_", " ")}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mx-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="capitalize" variant="outline">
{viewSource || defaultLiveMode || "Select A Live Mode"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select A Live Mode</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={`${viewSource}`}
onValueChange={setViewSource}
>
{restreamEnabled && (
<DropdownMenuRadioItem value="webrtc">
Webrtc
</DropdownMenuRadioItem>
)}
{restreamEnabled && (
<DropdownMenuRadioItem value="mse">
MSE
</DropdownMenuRadioItem>
)}
<DropdownMenuRadioItem value="jsmpeg">
Jsmpeg
</DropdownMenuRadioItem>
{camera != "birdseye" && (
<DropdownMenuRadioItem value="debug">
Debug
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<>
{events && events.length > 0 && (
<ScrollArea>
<TooltipProvider>
<div className="flex">
{events.map((event) => {
return (
<EventThumbnail
key={event.id}
event={event}
onFavorite={onFavorite}
/>
);
})}
</div>
</TooltipProvider>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
<div className="mt-4 md:grid md:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-4">
{cameras.map((camera) => {
let grow;
if (camera.detect.width / camera.detect.height > 2) {
grow = "aspect-wide md:col-span-2";
} else if (camera.detect.width / camera.detect.height < 1) {
grow = "aspect-tall md:aspect-auto md:row-span-2";
} else {
grow = "aspect-video";
}
return (
<LivePlayer
key={camera.name}
className={`mb-2 md:mb-0 rounded-2xl bg-black ${grow}`}
cameraConfig={camera}
preferredLiveMode="mse"
/>
);
})}
</div>
{config && camera == "birdseye" && sourceIsLoaded && (
<BirdseyeLivePlayer
birdseyeConfig={config?.birdseye}
liveMode={`${viewSource ?? defaultLiveMode}`}
/>
)}
{cameraConfig && sourceIsLoaded && (
<LivePlayer
liveMode={`${viewSource ?? defaultLiveMode}`}
cameraConfig={cameraConfig}
/>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import {
LuConstruction,
LuFileUp,
LuFilm,
LuVideo,
} from "react-icons/lu";
export const navbarLinks = [
{
id: 1,
icon: LuVideo,
title: "Live",
url: "/",
},
{
id: 2,
icon: LuFilm,
title: "History",
url: "/history",
},
{
id: 3,
icon: LuFileUp,
title: "Export",
url: "/export",
},
{
id: 4,
icon: LuConstruction,
title: "UI Playground",
url: "/playground",
dev: true,
},
];