mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-26 21:59:02 +03:00
Improve live streaming (#16447)
* config file changes * config migrator * stream selection on single camera live view * camera streaming settings dialog * manage persistent group streaming settings * apply streaming settings in camera groups * add ability to clear all streaming settings from settings * docs * update reference config * fixes * clarify docs * use first stream as default in dialog * ensure still image is visible after switching stream type to none * docs * clarify docs * add ability to continue playing stream in background * fix props * put stream selection inside dropdown on desktop * add capabilities to live mode hook * live context menu component * resize observer: only return new dimensions if they've actually changed * pass volume prop to players * fix slider bug, https://github.com/shadcn-ui/ui/issues/1448 * update react-grid-layout * prevent animated transitions on draggable grid layout * add context menu to dashboards * use provider * streaming dialog from context menu * docs * add jsmpeg warning to context menu * audio and two way talk indicators in single camera view * add link to debug view * don't use hook * create manual events from live camera view * maintain grow classes on grid items * fix initial volume state on default dashboard * fix pointer events causing context menu to end up underneath image on iOS * mobile drawer tweaks * stream stats * show settings menu for non-restreamed cameras * consistent settings icon * tweaks * optional stats to fix birdseye player * add toaster to live camera view * fix crash on initial save in streaming dialog * don't require restreaming for context menu streaming settings * add debug view to context menu * stats fixes * update docs * always show stream info when restreamed * update camera streaming dialog * make note of no h265 support for webrtc * docs clarity * ensure docs show streams as a dict * docs clarity * fix css file * tweaks
This commit is contained in:
@@ -40,9 +40,9 @@ export default function CameraFeatureToggle({
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
className,
|
||||
"flex flex-col items-center justify-center",
|
||||
variants[variant][isActive ? "active" : "inactive"],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
CameraGroupConfig,
|
||||
FrigateConfig,
|
||||
GroupStreamingSettings,
|
||||
} from "@/types/frigateConfig";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
@@ -43,7 +48,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import axios from "axios";
|
||||
import FilterSwitch from "./FilterSwitch";
|
||||
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import IconWrapper from "../ui/icon-wrapper";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -66,6 +70,11 @@ import {
|
||||
MobilePageHeader,
|
||||
MobilePageTitle,
|
||||
} from "../mobile/MobilePage";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
|
||||
type CameraGroupSelectorProps = {
|
||||
className?: string;
|
||||
@@ -607,6 +616,16 @@ export function CameraGroupEdit({
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
|
||||
const [groupStreamingSettings, setGroupStreamingSettings] =
|
||||
useState<GroupStreamingSettings>(
|
||||
allGroupsStreamingSettings[editingGroup?.[0] ?? ""],
|
||||
);
|
||||
|
||||
const [openCamera, setOpenCamera] = useState<string | null>();
|
||||
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -656,6 +675,16 @@ export function CameraGroupEdit({
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// update streaming settings
|
||||
const updatedSettings: AllGroupsStreamingSettings = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(allGroupsStreamingSettings || {}).filter(
|
||||
([key]) => key !== editingGroup?.[0],
|
||||
),
|
||||
),
|
||||
[values.name]: groupStreamingSettings,
|
||||
};
|
||||
|
||||
let renamingQuery = "";
|
||||
if (editingGroup && editingGroup[0] !== values.name) {
|
||||
renamingQuery = `camera_groups.${editingGroup[0]}&`;
|
||||
@@ -679,7 +708,7 @@ export function CameraGroupEdit({
|
||||
requires_restart: 0,
|
||||
},
|
||||
)
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
if (res.status === 200) {
|
||||
toast.success(`Camera group (${values.name}) has been saved.`, {
|
||||
position: "top-center",
|
||||
@@ -688,6 +717,7 @@ export function CameraGroupEdit({
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
setAllGroupsStreamingSettings(updatedSettings);
|
||||
} else {
|
||||
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||
position: "top-center",
|
||||
@@ -704,7 +734,16 @@ export function CameraGroupEdit({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[currentGroups, setIsLoading, onSave, updateConfig, editingGroup],
|
||||
[
|
||||
currentGroups,
|
||||
setIsLoading,
|
||||
onSave,
|
||||
updateConfig,
|
||||
editingGroup,
|
||||
groupStreamingSettings,
|
||||
allGroupsStreamingSettings,
|
||||
setAllGroupsStreamingSettings,
|
||||
],
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -762,16 +801,66 @@ export function CameraGroupEdit({
|
||||
),
|
||||
].map((camera) => (
|
||||
<FormControl key={camera}>
|
||||
<FilterSwitch
|
||||
isChecked={field.value && field.value.includes(camera)}
|
||||
label={camera.replaceAll("_", " ")}
|
||||
onCheckedChange={(checked) => {
|
||||
const updatedCameras = checked
|
||||
? [...(field.value || []), camera]
|
||||
: (field.value || []).filter((c) => c !== camera);
|
||||
form.setValue("cameras", updatedCameras);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Label
|
||||
className="mx-2 w-full cursor-pointer capitalize text-primary"
|
||||
htmlFor={camera.replaceAll("_", " ")}
|
||||
>
|
||||
{camera.replaceAll("_", " ")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
{camera !== "birdseye" && (
|
||||
<Dialog
|
||||
open={openCamera === camera}
|
||||
onOpenChange={(isOpen) =>
|
||||
setOpenCamera(isOpen ? camera : null)
|
||||
}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="flex h-auto items-center gap-1"
|
||||
aria-label="Camera streaming settings"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={
|
||||
!(field.value && field.value.includes(camera))
|
||||
}
|
||||
>
|
||||
<LuIcons.LuSettings
|
||||
className={cn(
|
||||
field.value && field.value.includes(camera)
|
||||
? "text-primary"
|
||||
: "text-muted-foreground",
|
||||
"size-5",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CameraStreamingDialog
|
||||
camera={camera}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={
|
||||
setGroupStreamingSettings
|
||||
}
|
||||
setIsDialogOpen={(isOpen) =>
|
||||
setOpenCamera(isOpen ? camera : null)
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
<Switch
|
||||
id={camera.replaceAll("_", " ")}
|
||||
checked={field.value && field.value.includes(camera)}
|
||||
onCheckedChange={(checked) => {
|
||||
const updatedCameras = checked
|
||||
? [...(field.value || []), camera]
|
||||
: (field.value || []).filter((c) => c !== camera);
|
||||
form.setValue("cameras", updatedCameras);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
))}
|
||||
</FormItem>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
MdVolumeDown,
|
||||
MdVolumeMute,
|
||||
MdVolumeOff,
|
||||
MdVolumeUp,
|
||||
} from "react-icons/md";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { VolumeSlider } from "@/components/ui/slider";
|
||||
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
GroupStreamingSettings,
|
||||
} from "@/types/frigateConfig";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
streamName: string;
|
||||
cameraGroup?: string;
|
||||
preferredLiveMode: string;
|
||||
isRestreamed: boolean;
|
||||
supportsAudio: boolean;
|
||||
audioState: boolean;
|
||||
toggleAudio: () => void;
|
||||
volumeState?: number;
|
||||
setVolumeState: (volumeState: number) => void;
|
||||
muteAll: () => void;
|
||||
unmuteAll: () => void;
|
||||
statsState: boolean;
|
||||
toggleStats: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
export default function LiveContextMenu({
|
||||
className,
|
||||
camera,
|
||||
streamName,
|
||||
cameraGroup,
|
||||
preferredLiveMode,
|
||||
isRestreamed,
|
||||
supportsAudio,
|
||||
audioState,
|
||||
toggleAudio,
|
||||
volumeState,
|
||||
setVolumeState,
|
||||
muteAll,
|
||||
unmuteAll,
|
||||
statsState,
|
||||
toggleStats,
|
||||
resetPreferredLiveMode,
|
||||
children,
|
||||
}: LiveContextMenuProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// streaming settings
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
|
||||
const [groupStreamingSettings, setGroupStreamingSettings] =
|
||||
useState<GroupStreamingSettings>(
|
||||
allGroupsStreamingSettings[cameraGroup ?? ""],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cameraGroup) {
|
||||
setGroupStreamingSettings(allGroupsStreamingSettings[cameraGroup]);
|
||||
}
|
||||
// set individual group when all groups changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allGroupsStreamingSettings]);
|
||||
|
||||
const onSave = useCallback(
|
||||
(settings: GroupStreamingSettings) => {
|
||||
if (!cameraGroup || !allGroupsStreamingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSettings: AllGroupsStreamingSettings = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(allGroupsStreamingSettings || {}).filter(
|
||||
([key]) => key !== cameraGroup,
|
||||
),
|
||||
),
|
||||
[cameraGroup]: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(settings).map(([cameraName, cameraSettings]) => [
|
||||
cameraName,
|
||||
cameraName === camera
|
||||
? {
|
||||
...cameraSettings,
|
||||
playAudio: audioState ?? cameraSettings.playAudio ?? false,
|
||||
volume: volumeState ?? cameraSettings.volume ?? 1,
|
||||
}
|
||||
: cameraSettings,
|
||||
]),
|
||||
),
|
||||
// Add the current camera if it doesn't exist
|
||||
...(!settings[camera]
|
||||
? {
|
||||
[camera]: {
|
||||
streamName: streamName,
|
||||
streamType: "smart",
|
||||
compatibilityMode: false,
|
||||
playAudio: audioState,
|
||||
volume: volumeState ?? 1,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
setAllGroupsStreamingSettings?.(updatedSettings);
|
||||
},
|
||||
[
|
||||
camera,
|
||||
streamName,
|
||||
cameraGroup,
|
||||
allGroupsStreamingSettings,
|
||||
setAllGroupsStreamingSettings,
|
||||
audioState,
|
||||
volumeState,
|
||||
],
|
||||
);
|
||||
|
||||
// ui
|
||||
|
||||
const audioControlsUsed = useRef(false);
|
||||
|
||||
const VolumeIcon = useMemo(() => {
|
||||
if (!volumeState || volumeState == 0.0 || !audioState) {
|
||||
return MdVolumeOff;
|
||||
} else if (volumeState <= 0.33) {
|
||||
return MdVolumeMute;
|
||||
} else if (volumeState <= 0.67) {
|
||||
return MdVolumeDown;
|
||||
} else {
|
||||
return MdVolumeUp;
|
||||
}
|
||||
// only update when specific fields change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [volumeState, audioState]);
|
||||
|
||||
const handleVolumeIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
audioControlsUsed.current = true;
|
||||
toggleAudio();
|
||||
};
|
||||
|
||||
const handleVolumeChange = (value: number[]) => {
|
||||
audioControlsUsed.current = true;
|
||||
setVolumeState(value[0]);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && audioControlsUsed.current) {
|
||||
onSave(groupStreamingSettings);
|
||||
audioControlsUsed.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// navigate for debug view
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
||||
<div className="text-md capitalize text-primary-variant">
|
||||
{camera.replaceAll("_", " ")}
|
||||
</div>
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<IoIosWarning className="mr-1 size-4 text-danger" />
|
||||
<p className="mr-2 text-xs">Low-bandwidth mode</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
|
||||
<>
|
||||
<ContextMenuSeparator className="mb-1" />
|
||||
<div className="p-2 text-sm">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<p>Audio</p>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<VolumeIcon
|
||||
className="size-5"
|
||||
onClick={handleVolumeIconClick}
|
||||
/>
|
||||
<VolumeSlider
|
||||
disabled={!audioState}
|
||||
className="my-3 ml-0.5 rounded-lg bg-background/60"
|
||||
value={[volumeState ?? 0]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
onValueChange={handleVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={muteAll}
|
||||
>
|
||||
<div className="text-primary">Mute All Cameras</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={unmuteAll}
|
||||
>
|
||||
<div className="text-primary">Unmute All Cameras</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={toggleStats}
|
||||
>
|
||||
<div className="text-primary">
|
||||
{statsState ? "Hide" : "Show"} Stream Stats
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={() => navigate(`/settings?page=debug&camera=${camera}`)}
|
||||
>
|
||||
<div className="text-primary">Debug View</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
{cameraGroup && cameraGroup !== "default" && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<div className="text-primary">Streaming Settings</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2"
|
||||
onClick={resetPreferredLiveMode}
|
||||
>
|
||||
<div className="text-primary">Reset</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<CameraStreamingDialog
|
||||
camera={camera}
|
||||
groupStreamingSettings={groupStreamingSettings}
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -673,7 +673,8 @@ export function ObjectSnapshotTab({
|
||||
</TransformComponent>
|
||||
{search.data.type == "object" &&
|
||||
search.plus_id !== "not_enabled" &&
|
||||
search.end_time && (
|
||||
search.end_time &&
|
||||
search.label != "on_demand" && (
|
||||
<Card className="p-1 text-sm md:p-2">
|
||||
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
|
||||
<div className={cn("flex flex-col space-y-3")}>
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function BirdseyeLivePlayer({
|
||||
height={birdseyeConfig.height}
|
||||
containerRef={containerRef}
|
||||
playbackEnabled={true}
|
||||
useWebGL={true}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlayerStatsType } from "@/types/live";
|
||||
// @ts-expect-error we know this doesn't have types
|
||||
import JSMpeg from "@cycjimmy/jsmpeg-player";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -12,6 +13,8 @@ type JSMpegPlayerProps = {
|
||||
height: number;
|
||||
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
playbackEnabled: boolean;
|
||||
useWebGL: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
};
|
||||
|
||||
@@ -22,6 +25,8 @@ export default function JSMpegPlayer({
|
||||
className,
|
||||
containerRef,
|
||||
playbackEnabled,
|
||||
useWebGL = false,
|
||||
setStats,
|
||||
onPlaying,
|
||||
}: JSMpegPlayerProps) {
|
||||
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
|
||||
@@ -33,6 +38,9 @@ export default function JSMpegPlayer({
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const hasDataRef = useRef(hasData);
|
||||
const [dimensionsReady, setDimensionsReady] = useState(false);
|
||||
const bytesReceivedRef = useRef(0);
|
||||
const lastTimestampRef = useRef(Date.now());
|
||||
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const selectedContainerRef = useMemo(
|
||||
() => (containerRef.current ? containerRef : internalContainerRef),
|
||||
@@ -111,6 +119,8 @@ export default function JSMpegPlayer({
|
||||
const canvas = canvasRef.current;
|
||||
let videoElement: JSMpeg.VideoElement | null = null;
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
setHasData(false);
|
||||
|
||||
if (videoWrapper && playbackEnabled) {
|
||||
@@ -123,21 +133,68 @@ export default function JSMpegPlayer({
|
||||
{
|
||||
protocols: [],
|
||||
audio: false,
|
||||
disableGl: camera != "birdseye",
|
||||
disableWebAssembly: camera != "birdseye",
|
||||
disableGl: !useWebGL,
|
||||
disableWebAssembly: !useWebGL,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
onVideoDecode: () => {
|
||||
if (!hasDataRef.current) {
|
||||
setHasData(true);
|
||||
onPlayingRef.current?.();
|
||||
}
|
||||
frameCount++;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Set up WebSocket message handler
|
||||
if (
|
||||
videoElement.player &&
|
||||
videoElement.player.source &&
|
||||
videoElement.player.source.socket
|
||||
) {
|
||||
const socket = videoElement.player.source.socket;
|
||||
socket.addEventListener("message", (event: MessageEvent) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
bytesReceivedRef.current += event.data.byteLength;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats every second
|
||||
statsIntervalRef.current = setInterval(() => {
|
||||
const currentTimestamp = Date.now();
|
||||
const timeDiff = (currentTimestamp - lastTimestampRef.current) / 1000; // in seconds
|
||||
const bitrate = (bytesReceivedRef.current * 8) / timeDiff / 1000; // in kbps
|
||||
|
||||
setStats?.({
|
||||
streamType: "jsmpeg",
|
||||
bandwidth: Math.round(bitrate),
|
||||
totalFrames: frameCount,
|
||||
latency: undefined,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: undefined,
|
||||
droppedFrameRate: undefined,
|
||||
});
|
||||
|
||||
bytesReceivedRef.current = 0;
|
||||
lastTimestampRef.current = currentTimestamp;
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current);
|
||||
frameCount = 0;
|
||||
statsIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initPlayer);
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current);
|
||||
statsIntervalRef.current = null;
|
||||
}
|
||||
if (videoElement) {
|
||||
try {
|
||||
// this causes issues in react strict mode
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import {
|
||||
LivePlayerError,
|
||||
LivePlayerMode,
|
||||
PlayerStatsType,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
@@ -20,20 +21,26 @@ import { cn } from "@/lib/utils";
|
||||
import { TbExclamationCircle } from "react-icons/tb";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { PlayerStats } from "./PlayerStats";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
cameraConfig: CameraConfig;
|
||||
streamName: string;
|
||||
preferredLiveMode: LivePlayerMode;
|
||||
showStillWithoutActivity?: boolean;
|
||||
useWebGL: boolean;
|
||||
windowVisible?: boolean;
|
||||
playAudio?: boolean;
|
||||
volume?: number;
|
||||
playInBackground: boolean;
|
||||
micEnabled?: boolean; // only webrtc supports mic
|
||||
iOSCompatFullScreen?: boolean;
|
||||
pip?: boolean;
|
||||
autoLive?: boolean;
|
||||
showStats?: boolean;
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
@@ -45,14 +52,19 @@ export default function LivePlayer({
|
||||
containerRef,
|
||||
className,
|
||||
cameraConfig,
|
||||
streamName,
|
||||
preferredLiveMode,
|
||||
showStillWithoutActivity = true,
|
||||
useWebGL = false,
|
||||
windowVisible = true,
|
||||
playAudio = false,
|
||||
volume,
|
||||
playInBackground = false,
|
||||
micEnabled = false,
|
||||
iOSCompatFullScreen = false,
|
||||
pip,
|
||||
autoLive = true,
|
||||
showStats = false,
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
@@ -60,6 +72,18 @@ export default function LivePlayer({
|
||||
}: LivePlayerProps) {
|
||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// stats
|
||||
|
||||
const [stats, setStats] = useState<PlayerStatsType>({
|
||||
streamType: "-",
|
||||
bandwidth: 0, // in kbps
|
||||
latency: undefined, // in seconds
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
droppedFrameRate: 0, // percentage
|
||||
});
|
||||
|
||||
// camera activity
|
||||
|
||||
const { activeMotion, activeTracking, objects, offline } =
|
||||
@@ -144,6 +168,25 @@ export default function LivePlayer({
|
||||
setLiveReady(false);
|
||||
}, [preferredLiveMode]);
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
const resetPlayer = () => {
|
||||
setLiveReady(false);
|
||||
setKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (streamName) {
|
||||
resetPlayer();
|
||||
}
|
||||
}, [streamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showStillWithoutActivity && !autoLive) {
|
||||
setLiveReady(false);
|
||||
}
|
||||
}, [showStillWithoutActivity, autoLive]);
|
||||
|
||||
const playerIsPlaying = useCallback(() => {
|
||||
setLiveReady(true);
|
||||
}, []);
|
||||
@@ -153,15 +196,19 @@ export default function LivePlayer({
|
||||
}
|
||||
|
||||
let player;
|
||||
if (!autoLive) {
|
||||
if (!autoLive || !streamName) {
|
||||
player = null;
|
||||
} else if (preferredLiveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
key={"webrtc_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
camera={cameraConfig.live.stream_name}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
microphoneEnabled={micEnabled}
|
||||
iOSCompatFullScreen={iOSCompatFullScreen}
|
||||
onPlaying={playerIsPlaying}
|
||||
@@ -173,10 +220,15 @@ export default function LivePlayer({
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
key={"mse_" + key}
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
camera={cameraConfig.live.stream_name}
|
||||
camera={streamName}
|
||||
playbackEnabled={cameraActive || liveReady}
|
||||
audioEnabled={playAudio}
|
||||
volume={volume}
|
||||
playInBackground={playInBackground}
|
||||
getStats={showStats}
|
||||
setStats={setStats}
|
||||
onPlaying={playerIsPlaying}
|
||||
pip={pip}
|
||||
setFullResolution={setFullResolution}
|
||||
@@ -194,6 +246,7 @@ export default function LivePlayer({
|
||||
if (cameraActive || !showStillWithoutActivity || liveReady) {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
key={"jsmpeg_" + key}
|
||||
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
|
||||
camera={cameraConfig.name}
|
||||
width={cameraConfig.detect.width}
|
||||
@@ -201,6 +254,8 @@ export default function LivePlayer({
|
||||
playbackEnabled={
|
||||
cameraActive || !showStillWithoutActivity || liveReady
|
||||
}
|
||||
useWebGL={useWebGL}
|
||||
setStats={setStats}
|
||||
containerRef={containerRef ?? internalContainerRef}
|
||||
onPlaying={playerIsPlaying}
|
||||
/>
|
||||
@@ -293,7 +348,7 @@ export default function LivePlayer({
|
||||
)}
|
||||
>
|
||||
<AutoUpdatingCameraImage
|
||||
className="size-full"
|
||||
className="pointer-events-none size-full"
|
||||
cameraClasses="relative size-full flex justify-center"
|
||||
camera={cameraConfig.name}
|
||||
showFps={false}
|
||||
@@ -331,6 +386,9 @@ export default function LivePlayer({
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
{showStats && (
|
||||
<PlayerStats stats={stats} minimal={cameraRef !== undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { LivePlayerError, VideoResolutionType } from "@/types/live";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import {
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
@@ -15,7 +19,11 @@ type MSEPlayerProps = {
|
||||
className?: string;
|
||||
playbackEnabled?: boolean;
|
||||
audioEnabled?: boolean;
|
||||
volume?: number;
|
||||
playInBackground?: boolean;
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
@@ -26,7 +34,11 @@ function MSEPlayer({
|
||||
className,
|
||||
playbackEnabled = true,
|
||||
audioEnabled = false,
|
||||
volume,
|
||||
playInBackground = false,
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
onPlaying,
|
||||
setFullResolution,
|
||||
onError,
|
||||
@@ -57,6 +69,7 @@ function MSEPlayer({
|
||||
const [connectTS, setConnectTS] = useState<number>(0);
|
||||
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -316,6 +329,8 @@ function MSEPlayer({
|
||||
let bufLen = 0;
|
||||
|
||||
ondataRef.current = (data) => {
|
||||
totalBytesLoaded.current += data.byteLength;
|
||||
|
||||
if (sb?.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
@@ -508,12 +523,22 @@ function MSEPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", listener);
|
||||
if (!playInBackground) {
|
||||
document.addEventListener("visibilitychange", listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", listener);
|
||||
if (!playInBackground) {
|
||||
document.removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
}, [playbackEnabled, visibilityCheck, onConnect, onDisconnect]);
|
||||
}, [
|
||||
playbackEnabled,
|
||||
visibilityCheck,
|
||||
playInBackground,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
]);
|
||||
|
||||
// control pip
|
||||
|
||||
@@ -525,6 +550,16 @@ function MSEPlayer({
|
||||
videoRef.current.requestPictureInPicture();
|
||||
}, [pip, videoRef]);
|
||||
|
||||
// control volume
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || volume == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
|
||||
// ensure we disconnect for slower connections
|
||||
|
||||
useEffect(() => {
|
||||
@@ -542,6 +577,68 @@ function MSEPlayer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playbackEnabled]);
|
||||
|
||||
// stats
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
let lastLoadedBytes = totalBytesLoaded.current;
|
||||
let lastTimestamp = Date.now();
|
||||
|
||||
if (!getStats) return;
|
||||
|
||||
const updateStats = () => {
|
||||
if (video) {
|
||||
const now = Date.now();
|
||||
const bytesLoaded = totalBytesLoaded.current;
|
||||
const timeElapsed = (now - lastTimestamp) / 1000; // seconds
|
||||
const bandwidth = (bytesLoaded - lastLoadedBytes) / timeElapsed / 1024; // kbps
|
||||
|
||||
lastLoadedBytes = bytesLoaded;
|
||||
lastTimestamp = now;
|
||||
|
||||
const latency =
|
||||
video.seekable.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
video.seekable.end(video.seekable.length - 1) -
|
||||
video.currentTime,
|
||||
)
|
||||
: 0;
|
||||
|
||||
const videoQuality = video.getVideoPlaybackQuality();
|
||||
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
|
||||
const droppedFrameRate = totalVideoFrames
|
||||
? (droppedVideoFrames / totalVideoFrames) * 100
|
||||
: 0;
|
||||
|
||||
setStats?.({
|
||||
streamType: "MSE",
|
||||
bandwidth,
|
||||
latency,
|
||||
totalFrames: totalVideoFrames,
|
||||
droppedFrames: droppedVideoFrames || undefined,
|
||||
decodedFrames: totalVideoFrames - droppedVideoFrames,
|
||||
droppedFrameRate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(updateStats, 1000); // Update every second
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
latency: undefined,
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
droppedFrameRate: 0,
|
||||
});
|
||||
};
|
||||
}, [setStats, getStats]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlayerStatsType } from "@/types/live";
|
||||
|
||||
type PlayerStatsProps = {
|
||||
stats: PlayerStatsType;
|
||||
minimal: boolean;
|
||||
};
|
||||
|
||||
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
|
||||
const fullStatsContent = (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-white/70">Stream Type:</span>{" "}
|
||||
<span className="text-white">{stats.streamType}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-white/70">Bandwidth:</span>{" "}
|
||||
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
|
||||
</p>
|
||||
{stats.latency != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">Latency:</span>{" "}
|
||||
<span
|
||||
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
|
||||
>
|
||||
{stats.latency.toFixed(2)} seconds
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="text-white/70">Total Frames:</span>{" "}
|
||||
<span className="text-white">{stats.totalFrames}</span>
|
||||
</p>
|
||||
{stats.droppedFrames != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">Dropped Frames:</span>{" "}
|
||||
<span className="text-white">{stats.droppedFrames}</span>
|
||||
</p>
|
||||
)}
|
||||
{stats.decodedFrames != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">Decoded Frames:</span>{" "}
|
||||
<span className="text-white">{stats.decodedFrames}</span>
|
||||
</p>
|
||||
)}
|
||||
{stats.droppedFrameRate != undefined && (
|
||||
<p>
|
||||
<span className="text-white/70">Dropped Frame Rate:</span>{" "}
|
||||
<span className="text-white">
|
||||
{stats.droppedFrameRate.toFixed(2)}%
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const minimalStatsContent = (
|
||||
<div className="flex flex-row items-center justify-center gap-4">
|
||||
<div className="flex flex-col items-center justify-start gap-1">
|
||||
<span className="text-white/70">Type</span>
|
||||
<span className="text-white">{stats.streamType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-white/70">Bandwidth</span>{" "}
|
||||
<span className="text-white">{stats.bandwidth.toFixed(2)} kbps</span>
|
||||
</div>
|
||||
{stats.latency != undefined && (
|
||||
<div className="hidden flex-col items-center gap-1 md:flex">
|
||||
<span className="text-white/70">Latency</span>
|
||||
<span
|
||||
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
|
||||
>
|
||||
{stats.latency.toFixed(2)} sec
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.droppedFrames != undefined && (
|
||||
<div className="flex flex-col items-center justify-end gap-1">
|
||||
<span className="text-white/70">Dropped</span>
|
||||
<span className="text-white">{stats.droppedFrames} frames</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
minimal
|
||||
? "absolute bottom-0 left-0 max-h-[50%] w-full overflow-y-auto rounded-b-lg p-1 md:rounded-b-xl md:p-3"
|
||||
: "absolute bottom-2 right-2 min-w-52 rounded-2xl p-4",
|
||||
"z-50 flex flex-col gap-1 bg-black/70 text-[9px] duration-300 animate-in fade-in md:text-xs",
|
||||
)}
|
||||
>
|
||||
{minimal ? minimalStatsContent : fullStatsContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { LivePlayerError } from "@/types/live";
|
||||
import { LivePlayerError, PlayerStatsType } from "@/types/live";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type WebRtcPlayerProps = {
|
||||
@@ -7,9 +7,12 @@ type WebRtcPlayerProps = {
|
||||
camera: string;
|
||||
playbackEnabled?: boolean;
|
||||
audioEnabled?: boolean;
|
||||
volume?: number;
|
||||
microphoneEnabled?: boolean;
|
||||
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
|
||||
pip?: boolean;
|
||||
getStats?: boolean;
|
||||
setStats?: (stats: PlayerStatsType) => void;
|
||||
onPlaying?: () => void;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
};
|
||||
@@ -19,9 +22,12 @@ export default function WebRtcPlayer({
|
||||
camera,
|
||||
playbackEnabled = true,
|
||||
audioEnabled = false,
|
||||
volume,
|
||||
microphoneEnabled = false,
|
||||
iOSCompatFullScreen = false,
|
||||
pip = false,
|
||||
getStats = false,
|
||||
setStats,
|
||||
onPlaying,
|
||||
onError,
|
||||
}: WebRtcPlayerProps) {
|
||||
@@ -194,6 +200,16 @@ export default function WebRtcPlayer({
|
||||
videoRef.current.requestPictureInPicture();
|
||||
}, [pip, videoRef]);
|
||||
|
||||
// control volume
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || volume == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
videoLoadTimeoutRef.current = setTimeout(() => {
|
||||
onError?.("stalled");
|
||||
@@ -215,6 +231,75 @@ export default function WebRtcPlayer({
|
||||
onPlaying?.();
|
||||
};
|
||||
|
||||
// stats
|
||||
|
||||
useEffect(() => {
|
||||
if (!pcRef.current || !getStats) return;
|
||||
|
||||
let lastBytesReceived = 0;
|
||||
let lastTimestamp = 0;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (pcRef.current && videoRef.current && !videoRef.current.paused) {
|
||||
const report = await pcRef.current.getStats();
|
||||
let bytesReceived = 0;
|
||||
let timestamp = 0;
|
||||
let roundTripTime = 0;
|
||||
let framesReceived = 0;
|
||||
let framesDropped = 0;
|
||||
let framesDecoded = 0;
|
||||
|
||||
report.forEach((stat) => {
|
||||
if (stat.type === "inbound-rtp" && stat.kind === "video") {
|
||||
bytesReceived = stat.bytesReceived;
|
||||
timestamp = stat.timestamp;
|
||||
framesReceived = stat.framesReceived;
|
||||
framesDropped = stat.framesDropped;
|
||||
framesDecoded = stat.framesDecoded;
|
||||
}
|
||||
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
|
||||
roundTripTime = stat.currentRoundTripTime;
|
||||
}
|
||||
});
|
||||
|
||||
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
|
||||
const bitrate =
|
||||
timeDiff > 0
|
||||
? (bytesReceived - lastBytesReceived) / timeDiff / 1000
|
||||
: 0; // in kbps
|
||||
|
||||
setStats?.({
|
||||
streamType: "WebRTC",
|
||||
bandwidth: Math.round(bitrate),
|
||||
latency: roundTripTime,
|
||||
totalFrames: framesReceived,
|
||||
droppedFrames: framesDropped,
|
||||
decodedFrames: framesDecoded,
|
||||
droppedFrameRate:
|
||||
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
|
||||
});
|
||||
|
||||
lastBytesReceived = bytesReceived;
|
||||
lastTimestamp = timestamp;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setStats?.({
|
||||
streamType: "-",
|
||||
bandwidth: 0,
|
||||
latency: undefined,
|
||||
totalFrames: 0,
|
||||
droppedFrames: undefined,
|
||||
decodedFrames: 0,
|
||||
droppedFrameRate: 0,
|
||||
});
|
||||
};
|
||||
// we need to listen on the value of the ref
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pcRef, pcRef.current, getStats]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
FrigateConfig,
|
||||
GroupStreamingSettings,
|
||||
StreamType,
|
||||
} from "@/types/frigateConfig";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import useSWR from "swr";
|
||||
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
type CameraStreamingDialogProps = {
|
||||
camera: string;
|
||||
groupStreamingSettings: GroupStreamingSettings;
|
||||
setGroupStreamingSettings: React.Dispatch<
|
||||
React.SetStateAction<GroupStreamingSettings>
|
||||
>;
|
||||
setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSave?: (settings: GroupStreamingSettings) => void;
|
||||
};
|
||||
|
||||
export function CameraStreamingDialog({
|
||||
camera,
|
||||
groupStreamingSettings,
|
||||
setGroupStreamingSettings,
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
}: CameraStreamingDialogProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [streamName, setStreamName] = useState(
|
||||
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
|
||||
);
|
||||
const [streamType, setStreamType] = useState<StreamType>("smart");
|
||||
const [compatibilityMode, setCompatibilityMode] = useState(false);
|
||||
|
||||
// metadata
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const supportsAudioOutput = useMemo(() => {
|
||||
if (!cameraMetadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
cameraMetadata.producers.find(
|
||||
(prod) =>
|
||||
prod.medias &&
|
||||
prod.medias.find((media) => media.includes("audio, recvonly")) !=
|
||||
undefined,
|
||||
) != undefined
|
||||
);
|
||||
}, [cameraMetadata]);
|
||||
|
||||
// handlers
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (groupStreamingSettings && groupStreamingSettings[camera]) {
|
||||
const cameraSettings = groupStreamingSettings[camera];
|
||||
setStreamName(cameraSettings.streamName || "");
|
||||
setStreamType(cameraSettings.streamType || "smart");
|
||||
setCompatibilityMode(cameraSettings.compatibilityMode || false);
|
||||
} else {
|
||||
setStreamName(
|
||||
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
|
||||
"",
|
||||
);
|
||||
setStreamType("smart");
|
||||
setCompatibilityMode(false);
|
||||
}
|
||||
}, [groupStreamingSettings, camera, config]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
const updatedSettings = {
|
||||
...groupStreamingSettings,
|
||||
[camera]: {
|
||||
streamName,
|
||||
streamType,
|
||||
compatibilityMode,
|
||||
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
|
||||
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
setGroupStreamingSettings(updatedSettings);
|
||||
setIsDialogOpen(false);
|
||||
setIsLoading(false);
|
||||
onSave?.(updatedSettings);
|
||||
}, [
|
||||
groupStreamingSettings,
|
||||
setGroupStreamingSettings,
|
||||
camera,
|
||||
streamName,
|
||||
streamType,
|
||||
compatibilityMode,
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (groupStreamingSettings && groupStreamingSettings[camera]) {
|
||||
const cameraSettings = groupStreamingSettings[camera];
|
||||
setStreamName(cameraSettings.streamName || "");
|
||||
setStreamType(cameraSettings.streamType || "smart");
|
||||
setCompatibilityMode(cameraSettings.compatibilityMode || false);
|
||||
} else {
|
||||
setStreamName(
|
||||
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] ||
|
||||
"",
|
||||
);
|
||||
setStreamType("smart");
|
||||
setCompatibilityMode(false);
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
}, [groupStreamingSettings, camera, config, setIsDialogOpen]);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="capitalize">
|
||||
{camera.replaceAll("_", " ")} Streaming Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the live streaming options for this camera group's dashboard.{" "}
|
||||
<em>These settings are device/browser-specific.</em>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col space-y-8">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Stream</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Restreaming is not enabled for this camera.</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Set up go2rtc for additional live view options and audio for
|
||||
this camera.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="stream" className="text-right">
|
||||
Stream
|
||||
</Label>
|
||||
<Select value={streamName} onValueChange={setStreamName}>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue placeholder="Choose a stream" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{camera !== "birdseye" &&
|
||||
Object.entries(config?.cameras[camera].live.streams).map(
|
||||
([name, stream]) => (
|
||||
<SelectItem key={stream} value={stream}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Audio is available for this stream</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Audio is unavailable for this stream</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Audio must be output from your camera and configured
|
||||
in go2rtc for this stream.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="streaming-method" className="text-right">
|
||||
Streaming Method
|
||||
</Label>
|
||||
<Select
|
||||
value={streamType}
|
||||
onValueChange={(value) => setStreamType(value as StreamType)}
|
||||
>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue placeholder="Choose a streaming option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="no-streaming">No Streaming</SelectItem>
|
||||
<SelectItem value="smart">
|
||||
Smart Streaming (recommended)
|
||||
</SelectItem>
|
||||
<SelectItem value="continuous">Continuous Streaming</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{streamType === "no-streaming" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Camera images will only update once per minute and no live
|
||||
streaming will occur.
|
||||
</p>
|
||||
)}
|
||||
{streamType === "smart" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Smart streaming will update your camera image once per minute when
|
||||
no detectable activity is occurring to conserve bandwidth and
|
||||
resources. When activity is detected, the image seamlessly
|
||||
switches to a live stream.
|
||||
</p>
|
||||
)}
|
||||
{streamType === "continuous" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Camera image will always be a live stream when visible on the
|
||||
dashboard, even if no activity is being detected.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<IoIosWarning className="mr-2 size-5 text-danger" />
|
||||
<div className="max-w-[85%] text-sm">
|
||||
Continuous streaming may cause high bandwidth usage and
|
||||
performance issues. Use with caution.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compatibility"
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={compatibilityMode}
|
||||
onCheckedChange={() => setCompatibilityMode(!compatibilityMode)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="compatibility"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Compatibility mode
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 leading-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable this option only if your camera's live stream is displaying
|
||||
color artifacts and has a diagonal line on the right side of the
|
||||
image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label="Cancel"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label="Save"
|
||||
disabled={isLoading}
|
||||
className="flex flex-1"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const Slider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full cursor-pointer border-2 border-primary bg-primary ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-primary ring-offset-background transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
@@ -36,9 +36,9 @@ const VolumeSlider = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-white" />
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary data-[disabled]:opacity-20" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-white ring-white focus:ring-white disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full bg-primary ring-primary data-[disabled]:pointer-events-none data-[disabled]:bg-muted focus:ring-primary" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
VolumeSlider.displayName = SliderPrimitive.Root.displayName;
|
||||
@@ -58,7 +58,7 @@ const NoThumbSlider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-full w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-selected" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-16 rounded-full bg-transparent -translate-y-[50%] ring-offset-transparent focus-visible:outline-none focus-visible:ring-transparent disabled:pointer-events-none disabled:opacity-50 cursor-col-resize" />
|
||||
<SliderPrimitive.Thumb className="block h-4 w-16 -translate-y-[50%] cursor-col-resize rounded-full bg-transparent ring-offset-transparent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus-visible:outline-none focus-visible:ring-transparent" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
NoThumbSlider.displayName = SliderPrimitive.Root.displayName;
|
||||
@@ -78,8 +78,8 @@ const DualThumbSlider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-selected/60">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-selected" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block size-3 rounded-full bg-selected transition-colors cursor-col-resize disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block size-3 cursor-col-resize rounded-full bg-selected transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
DualThumbSlider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiProvider } from "@/api";
|
||||
import { IconContext } from "react-icons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
|
||||
import { StreamingSettingsProvider } from "./streaming-settings-provider";
|
||||
|
||||
type TProvidersProps = {
|
||||
children: ReactNode;
|
||||
@@ -17,7 +18,11 @@ function providers({ children }: TProvidersProps) {
|
||||
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
|
||||
<TooltipProvider>
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
<StatusBarMessagesProvider>{children}</StatusBarMessagesProvider>
|
||||
<StatusBarMessagesProvider>
|
||||
<StreamingSettingsProvider>
|
||||
{children}
|
||||
</StreamingSettingsProvider>
|
||||
</StatusBarMessagesProvider>
|
||||
</IconContext.Provider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
|
||||
type StreamingSettingsContextType = {
|
||||
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
||||
setAllGroupsStreamingSettings: (settings: AllGroupsStreamingSettings) => void;
|
||||
isPersistedStreamingSettingsLoaded: boolean;
|
||||
};
|
||||
|
||||
const StreamingSettingsContext =
|
||||
createContext<StreamingSettingsContextType | null>(null);
|
||||
|
||||
export function StreamingSettingsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [allGroupsStreamingSettings, setAllGroupsStreamingSettings] =
|
||||
useState<AllGroupsStreamingSettings>({});
|
||||
|
||||
const [
|
||||
persistedGroupStreamingSettings,
|
||||
setPersistedGroupStreamingSettings,
|
||||
isPersistedStreamingSettingsLoaded,
|
||||
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (isPersistedStreamingSettingsLoaded) {
|
||||
setAllGroupsStreamingSettings(persistedGroupStreamingSettings ?? {});
|
||||
}
|
||||
}, [isPersistedStreamingSettingsLoaded, persistedGroupStreamingSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(allGroupsStreamingSettings).length) {
|
||||
setPersistedGroupStreamingSettings(allGroupsStreamingSettings);
|
||||
}
|
||||
}, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]);
|
||||
|
||||
return (
|
||||
<StreamingSettingsContext.Provider
|
||||
value={{
|
||||
allGroupsStreamingSettings,
|
||||
setAllGroupsStreamingSettings,
|
||||
isPersistedStreamingSettingsLoaded,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StreamingSettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useStreamingSettings() {
|
||||
const context = useContext(StreamingSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useStreamingSettings must be used within a StreamingSettingsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -17,7 +17,15 @@ export function useResizeObserver(...refs: RefType[]) {
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setDimensions(entries.map((entry) => entry.contentRect));
|
||||
setDimensions((prevDimensions) => {
|
||||
const newDimensions = entries.map((entry) => entry.contentRect);
|
||||
if (
|
||||
JSON.stringify(prevDimensions) !== JSON.stringify(newDimensions)
|
||||
) {
|
||||
return newDimensions;
|
||||
}
|
||||
return prevDimensions;
|
||||
});
|
||||
});
|
||||
}),
|
||||
[],
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
windowVisible: boolean,
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: allStreamMetadata } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(config ? "go2rtc/streams" : null, { revalidateOnFocus: false });
|
||||
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
const [isRestreamedStates, setIsRestreamedStates] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
const [supportsAudioOutputStates, setSupportsAudioOutputStates] = useState<{
|
||||
[key: string]: {
|
||||
supportsAudio: boolean;
|
||||
cameraName: string;
|
||||
};
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!cameras) return;
|
||||
@@ -18,26 +31,56 @@ export default function useCameraLiveMode(
|
||||
const mseSupported =
|
||||
"MediaSource" in window || "ManagedMediaSource" in window;
|
||||
|
||||
const newPreferredLiveModes = cameras.reduce(
|
||||
(acc, camera) => {
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
camera.live.stream_name,
|
||||
);
|
||||
const newPreferredLiveModes: { [key: string]: LivePlayerMode } = {};
|
||||
const newIsRestreamedStates: { [key: string]: boolean } = {};
|
||||
const newSupportsAudioOutputStates: {
|
||||
[key: string]: { supportsAudio: boolean; cameraName: string };
|
||||
} = {};
|
||||
|
||||
if (!mseSupported) {
|
||||
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as { [key: string]: LivePlayerMode },
|
||||
);
|
||||
cameras.forEach((camera) => {
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
|
||||
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
|
||||
|
||||
if (!mseSupported) {
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
|
||||
} else {
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
|
||||
// check each stream for audio support
|
||||
if (isRestreamed) {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const metadata = allStreamMetadata?.[streamName];
|
||||
newSupportsAudioOutputStates[streamName] = {
|
||||
supportsAudio: metadata
|
||||
? metadata.producers.find(
|
||||
(prod) =>
|
||||
prod.medias &&
|
||||
prod.medias.find((media) =>
|
||||
media.includes("audio, recvonly"),
|
||||
) !== undefined,
|
||||
) !== undefined
|
||||
: false,
|
||||
cameraName: camera.name,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
newSupportsAudioOutputStates[camera.name] = {
|
||||
supportsAudio: false,
|
||||
cameraName: camera.name,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
}, [cameras, config, windowVisible]);
|
||||
setIsRestreamedStates(newIsRestreamedStates);
|
||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||
}, [cameras, config, windowVisible, allStreamMetadata]);
|
||||
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
@@ -61,5 +104,11 @@ export default function useCameraLiveMode(
|
||||
[config],
|
||||
);
|
||||
|
||||
return { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode };
|
||||
return {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ html {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.react-grid-layout,
|
||||
.react-grid-layout .react-grid-item {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.react-lazylog,
|
||||
.react-lazylog-searchbar {
|
||||
background-color: transparent !important;
|
||||
|
||||
@@ -37,6 +37,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView";
|
||||
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||
import SearchSettingsView from "@/views/settings/SearchSettingsView";
|
||||
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
|
||||
const allSettingsViews = [
|
||||
"UI settings",
|
||||
@@ -119,6 +120,21 @@ export default function Settings() {
|
||||
}
|
||||
}, [tabsRef, pageToggle]);
|
||||
|
||||
useSearchEffect("page", (page: string) => {
|
||||
if (allSettingsViews.includes(page as SettingsType)) {
|
||||
setPage(page as SettingsType);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useSearchEffect("camera", (camera: string) => {
|
||||
const cameraNames = cameras.map((c) => c.name);
|
||||
if (cameraNames.includes(camera)) {
|
||||
setSelectedCamera(camera);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Settings - Frigate";
|
||||
}, []);
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface CameraConfig {
|
||||
live: {
|
||||
height: number;
|
||||
quality: number;
|
||||
stream_name: string;
|
||||
streams: { [key: string]: string };
|
||||
};
|
||||
motion: {
|
||||
contour_area: number;
|
||||
@@ -175,10 +175,18 @@ export interface CameraConfig {
|
||||
alerts: {
|
||||
required_zones: string[];
|
||||
labels: string[];
|
||||
retain: {
|
||||
days: number;
|
||||
mode: string;
|
||||
};
|
||||
};
|
||||
detections: {
|
||||
required_zones: string[];
|
||||
labels: string[];
|
||||
retain: {
|
||||
days: number;
|
||||
mode: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
rtmp: {
|
||||
@@ -230,6 +238,24 @@ export type CameraGroupConfig = {
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type StreamType = "no-streaming" | "smart" | "continuous";
|
||||
|
||||
export type CameraStreamingSettings = {
|
||||
streamName: string;
|
||||
streamType: StreamType;
|
||||
compatibilityMode: boolean;
|
||||
playAudio: boolean;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type GroupStreamingSettings = {
|
||||
[cameraName: string]: CameraStreamingSettings;
|
||||
};
|
||||
|
||||
export type AllGroupsStreamingSettings = {
|
||||
[groupName: string]: GroupStreamingSettings;
|
||||
};
|
||||
|
||||
export interface FrigateConfig {
|
||||
audio: {
|
||||
enabled: boolean;
|
||||
@@ -326,12 +352,6 @@ export interface FrigateConfig {
|
||||
|
||||
camera_groups: { [groupName: string]: CameraGroupConfig };
|
||||
|
||||
live: {
|
||||
height: number;
|
||||
quality: number;
|
||||
stream_name: string;
|
||||
};
|
||||
|
||||
logger: {
|
||||
default: string;
|
||||
logs: Record<string, string>;
|
||||
|
||||
@@ -32,3 +32,17 @@ export type LiveStreamMetadata = {
|
||||
};
|
||||
|
||||
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
|
||||
|
||||
export type AudioState = Record<string, boolean>;
|
||||
export type StatsState = Record<string, boolean>;
|
||||
export type VolumeState = Record<string, number>;
|
||||
|
||||
export type PlayerStatsType = {
|
||||
streamType: string;
|
||||
bandwidth: number;
|
||||
latency: number | undefined;
|
||||
totalFrames: number;
|
||||
droppedFrames: number | undefined;
|
||||
decodedFrames: number | undefined;
|
||||
droppedFrameRate: number | undefined;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
BirdseyeConfig,
|
||||
CameraConfig,
|
||||
FrigateConfig,
|
||||
@@ -20,7 +21,12 @@ import {
|
||||
} from "react-grid-layout";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import "react-resizable/css/styles.css";
|
||||
import { LivePlayerError, LivePlayerMode } from "@/types/live";
|
||||
import {
|
||||
AudioState,
|
||||
LivePlayerMode,
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
@@ -42,6 +48,8 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
@@ -76,8 +84,26 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
|
||||
useCameraLiveMode(cameras, windowVisible);
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
|
||||
const currentGroupStreamingSettings = useMemo(() => {
|
||||
if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) {
|
||||
return allGroupsStreamingSettings[cameraGroup];
|
||||
}
|
||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
||||
|
||||
// grid layout
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
@@ -342,6 +368,105 @@ export default function DraggableGridLayout({
|
||||
placeholder.h = layoutItem.h;
|
||||
};
|
||||
|
||||
// audio and stats states
|
||||
|
||||
const [audioStates, setAudioStates] = useState<AudioState>({});
|
||||
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
|
||||
const [statsStates, setStatsStates] = useState<StatsState>(() => {
|
||||
const initialStates: StatsState = {};
|
||||
cameras.forEach((camera) => {
|
||||
initialStates[camera.name] = false;
|
||||
});
|
||||
return initialStates;
|
||||
});
|
||||
|
||||
const toggleStats = (cameraName: string): void => {
|
||||
setStatsStates((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: !prev[cameraName],
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!allGroupsStreamingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAudioStates: AudioState = {};
|
||||
const initialVolumeStates: VolumeState = {};
|
||||
|
||||
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
||||
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
||||
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
||||
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
||||
});
|
||||
});
|
||||
|
||||
setAudioStates(initialAudioStates);
|
||||
setVolumeStates(initialVolumeStates);
|
||||
}, [allGroupsStreamingSettings]);
|
||||
|
||||
const toggleAudio = (cameraName: string) => {
|
||||
setAudioStates((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: !prev[cameraName],
|
||||
}));
|
||||
};
|
||||
|
||||
const onSaveMuting = useCallback(
|
||||
(playAudio: boolean) => {
|
||||
if (!cameraGroup || !allGroupsStreamingSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingGroupSettings =
|
||||
allGroupsStreamingSettings[cameraGroup] || {};
|
||||
|
||||
const updatedSettings: AllGroupsStreamingSettings = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(allGroupsStreamingSettings || {}).filter(
|
||||
([key]) => key !== cameraGroup,
|
||||
),
|
||||
),
|
||||
[cameraGroup]: {
|
||||
...existingGroupSettings,
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingGroupSettings).map(
|
||||
([cameraName, settings]) => [
|
||||
cameraName,
|
||||
{
|
||||
...settings,
|
||||
playAudio: playAudio,
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
setAllGroupsStreamingSettings?.(updatedSettings);
|
||||
},
|
||||
[cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings],
|
||||
);
|
||||
|
||||
const muteAll = () => {
|
||||
const updatedStates: AudioState = {};
|
||||
cameras.forEach((camera) => {
|
||||
updatedStates[camera.name] = false;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
onSaveMuting(false);
|
||||
};
|
||||
|
||||
const unmuteAll = () => {
|
||||
const updatedStates: AudioState = {};
|
||||
cameras.forEach((camera) => {
|
||||
updatedStates[camera.name] = true;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
onSaveMuting(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -364,7 +489,7 @@ export default function DraggableGridLayout({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="no-scrollbar my-2 overflow-x-hidden px-2 pb-8"
|
||||
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
|
||||
ref={gridContainerRef}
|
||||
>
|
||||
<EditGroupDialog
|
||||
@@ -420,40 +545,87 @@ export default function DraggableGridLayout({
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const streamName =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName ||
|
||||
Object.values(camera.live.streams)[0];
|
||||
const autoLive =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamType !==
|
||||
"no-streaming";
|
||||
const showStillWithoutActivity =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamType !==
|
||||
"continuous";
|
||||
const useWebGL =
|
||||
currentGroupStreamingSettings?.[camera.name]
|
||||
?.compatibilityMode || false;
|
||||
return (
|
||||
<LivePlayerGridItem
|
||||
<GridLiveContextMenu
|
||||
className={grow}
|
||||
key={camera.name}
|
||||
cameraRef={cameraRef}
|
||||
className={cn(
|
||||
"rounded-lg bg-black md:rounded-2xl",
|
||||
grow,
|
||||
isEditMode &&
|
||||
showCircles &&
|
||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
)}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
cameraConfig={camera}
|
||||
camera={camera.name}
|
||||
streamName={streamName}
|
||||
cameraGroup={cameraGroup}
|
||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
||||
onClick={() => {
|
||||
!isEditMode && onSelectCamera(camera.name);
|
||||
}}
|
||||
onError={(e) => {
|
||||
setPreferredLiveModes((prevModes) => {
|
||||
const newModes = { ...prevModes };
|
||||
if (e === "mse-decode") {
|
||||
newModes[camera.name] = "webrtc";
|
||||
} else {
|
||||
newModes[camera.name] = "jsmpeg";
|
||||
}
|
||||
return newModes;
|
||||
});
|
||||
}}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
isRestreamed={isRestreamedStates[camera.name]}
|
||||
supportsAudio={
|
||||
supportsAudioOutputStates[streamName].supportsAudio
|
||||
}
|
||||
audioState={audioStates[camera.name]}
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name]}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates({
|
||||
[camera.name]: value,
|
||||
})
|
||||
}
|
||||
muteAll={muteAll}
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={() =>
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
>
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
streamName={streamName}
|
||||
autoLive={autoLive ?? globalAutoLive}
|
||||
showStillWithoutActivity={showStillWithoutActivity ?? true}
|
||||
useWebGL={useWebGL}
|
||||
cameraRef={cameraRef}
|
||||
className={cn(
|
||||
"rounded-lg bg-black md:rounded-2xl",
|
||||
grow,
|
||||
isEditMode &&
|
||||
showCircles &&
|
||||
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
||||
)}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
cameraConfig={camera}
|
||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
||||
playInBackground={false}
|
||||
showStats={statsStates[camera.name]}
|
||||
onClick={() => {
|
||||
!isEditMode && onSelectCamera(camera.name);
|
||||
}}
|
||||
onError={(e) => {
|
||||
setPreferredLiveModes((prevModes) => {
|
||||
const newModes = { ...prevModes };
|
||||
if (e === "mse-decode") {
|
||||
newModes[camera.name] = "webrtc";
|
||||
} else {
|
||||
newModes[camera.name] = "jsmpeg";
|
||||
}
|
||||
return newModes;
|
||||
});
|
||||
}}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name]}
|
||||
volume={volumeStates[camera.name]}
|
||||
/>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</LivePlayerGridItem>
|
||||
</GridLiveContextMenu>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGridLayout>
|
||||
@@ -596,41 +768,57 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
|
||||
},
|
||||
);
|
||||
|
||||
type LivePlayerGridItemProps = {
|
||||
type GridLiveContextMenuProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
className: string;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
cameraRef: (node: HTMLElement | null) => void;
|
||||
windowVisible: boolean;
|
||||
cameraConfig: CameraConfig;
|
||||
preferredLiveMode: LivePlayerMode;
|
||||
onClick: () => void;
|
||||
onError: (e: LivePlayerError) => void;
|
||||
onResetLiveMode: () => void;
|
||||
camera: string;
|
||||
streamName: string;
|
||||
cameraGroup: string;
|
||||
preferredLiveMode: string;
|
||||
isRestreamed: boolean;
|
||||
supportsAudio: boolean;
|
||||
audioState: boolean;
|
||||
toggleAudio: () => void;
|
||||
statsState: boolean;
|
||||
toggleStats: () => void;
|
||||
volumeState?: number;
|
||||
setVolumeState: (volumeState: number) => void;
|
||||
muteAll: () => void;
|
||||
unmuteAll: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
};
|
||||
|
||||
const LivePlayerGridItem = React.forwardRef<
|
||||
const GridLiveContextMenu = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
LivePlayerGridItemProps
|
||||
GridLiveContextMenuProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
style,
|
||||
className,
|
||||
style,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onTouchEnd,
|
||||
children,
|
||||
cameraRef,
|
||||
windowVisible,
|
||||
cameraConfig,
|
||||
camera,
|
||||
streamName,
|
||||
cameraGroup,
|
||||
preferredLiveMode,
|
||||
onClick,
|
||||
onError,
|
||||
onResetLiveMode,
|
||||
isRestreamed,
|
||||
supportsAudio,
|
||||
audioState,
|
||||
toggleAudio,
|
||||
statsState,
|
||||
toggleStats,
|
||||
volumeState,
|
||||
setVolumeState,
|
||||
muteAll,
|
||||
unmuteAll,
|
||||
resetPreferredLiveMode,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -644,18 +832,26 @@ const LivePlayerGridItem = React.forwardRef<
|
||||
onTouchEnd={onTouchEnd}
|
||||
{...props}
|
||||
>
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
<LiveContextMenu
|
||||
className={className}
|
||||
windowVisible={windowVisible}
|
||||
cameraConfig={cameraConfig}
|
||||
camera={camera}
|
||||
streamName={streamName}
|
||||
cameraGroup={cameraGroup}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
onResetLiveMode={onResetLiveMode}
|
||||
containerRef={ref as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
{children}
|
||||
isRestreamed={isRestreamed}
|
||||
supportsAudio={supportsAudio}
|
||||
audioState={audioState}
|
||||
toggleAudio={toggleAudio}
|
||||
statsState={statsState}
|
||||
toggleStats={toggleStats}
|
||||
volumeState={volumeState}
|
||||
setVolumeState={setVolumeState}
|
||||
muteAll={muteAll}
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
>
|
||||
{children}
|
||||
</LiveContextMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -62,29 +67,52 @@ import {
|
||||
FaMicrophoneSlash,
|
||||
} from "react-icons/fa";
|
||||
import { GiSpeaker, GiSpeakerOff } from "react-icons/gi";
|
||||
import { TbViewfinder, TbViewfinderOff } from "react-icons/tb";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import {
|
||||
TbRecordMail,
|
||||
TbRecordMailOff,
|
||||
TbViewfinder,
|
||||
TbViewfinderOff,
|
||||
} from "react-icons/tb";
|
||||
import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io";
|
||||
import {
|
||||
LuCheck,
|
||||
LuEar,
|
||||
LuEarOff,
|
||||
LuExternalLink,
|
||||
LuHistory,
|
||||
LuInfo,
|
||||
LuPictureInPicture,
|
||||
LuVideo,
|
||||
LuVideoOff,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import {
|
||||
MdNoPhotography,
|
||||
MdOutlineRestartAlt,
|
||||
MdPersonOff,
|
||||
MdPersonSearch,
|
||||
MdPhotoCamera,
|
||||
MdZoomIn,
|
||||
MdZoomOut,
|
||||
} from "react-icons/md";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import useSWR from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
type LiveCameraViewProps = {
|
||||
config?: FrigateConfig;
|
||||
@@ -109,17 +137,20 @@ export default function LiveCameraView({
|
||||
|
||||
// supported features
|
||||
|
||||
const [streamName, setStreamName] = usePersistence<string>(
|
||||
`${camera.name}-stream`,
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
|
||||
const isRestreamed = useMemo(
|
||||
() =>
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
camera.live.stream_name,
|
||||
),
|
||||
[camera, config],
|
||||
Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""),
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${camera.live.stream_name}` : null,
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
@@ -209,6 +240,13 @@ export default function LiveCameraView({
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
|
||||
`${camera.name}-background-play`,
|
||||
false,
|
||||
);
|
||||
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
|
||||
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -337,6 +375,7 @@ export default function LiveCameraView({
|
||||
|
||||
return (
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div
|
||||
ref={mainRef}
|
||||
className={
|
||||
@@ -460,13 +499,24 @@ export default function LiveCameraView({
|
||||
/>
|
||||
)}
|
||||
<FrigateCameraFeatures
|
||||
camera={camera.name}
|
||||
camera={camera}
|
||||
recordingEnabled={camera.record.enabled_in_config}
|
||||
audioDetectEnabled={camera.audio.enabled_in_config}
|
||||
autotrackingEnabled={
|
||||
camera.onvif.autotracking.enabled_in_config
|
||||
}
|
||||
fullscreen={fullscreen}
|
||||
streamName={streamName ?? ""}
|
||||
setStreamName={setStreamName}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
playInBackground={playInBackground ?? false}
|
||||
setPlayInBackground={setPlayInBackground}
|
||||
showStats={showStats}
|
||||
setShowStats={setShowStats}
|
||||
isRestreamed={isRestreamed ?? false}
|
||||
setLowBandwidth={setLowBandwidth}
|
||||
supportsAudioOutput={supportsAudioOutput}
|
||||
supports2WayTalk={supports2WayTalk}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -499,9 +549,13 @@ export default function LiveCameraView({
|
||||
showStillWithoutActivity={false}
|
||||
cameraConfig={camera}
|
||||
playAudio={audio}
|
||||
playInBackground={playInBackground ?? false}
|
||||
showStats={showStats}
|
||||
micEnabled={mic}
|
||||
iOSCompatFullScreen={isIOS}
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
useWebGL={true}
|
||||
streamName={streamName ?? ""}
|
||||
pip={pip}
|
||||
containerRef={containerRef}
|
||||
setFullResolution={setFullResolution}
|
||||
@@ -816,12 +870,49 @@ function PtzControlPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
|
||||
const rankMap = { all: 0, motion: 1, active_objects: 2 };
|
||||
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
|
||||
const mode = retain?.mode;
|
||||
return mode && mode in rankMap ? (mode as keyof typeof rankMap) : "all";
|
||||
};
|
||||
|
||||
const recordRetainMode = getValidMode(camera.record.retain);
|
||||
const alertsRetainMode = getValidMode(camera.review.alerts.retain);
|
||||
|
||||
const effectiveRetainMode =
|
||||
rankMap[alertsRetainMode] < rankMap[recordRetainMode]
|
||||
? recordRetainMode
|
||||
: alertsRetainMode;
|
||||
|
||||
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
|
||||
|
||||
return effectiveRetainMode !== "all" ? (
|
||||
<div>
|
||||
Your {source} recording retention configuration is set to{" "}
|
||||
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will
|
||||
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
type FrigateCameraFeaturesProps = {
|
||||
camera: string;
|
||||
camera: CameraConfig;
|
||||
recordingEnabled: boolean;
|
||||
audioDetectEnabled: boolean;
|
||||
autotrackingEnabled: boolean;
|
||||
fullscreen: boolean;
|
||||
streamName: string;
|
||||
setStreamName?: (value: string | undefined) => void;
|
||||
preferredLiveMode: string;
|
||||
playInBackground: boolean;
|
||||
setPlayInBackground: (value: boolean | undefined) => void;
|
||||
showStats: boolean;
|
||||
setShowStats: (value: boolean) => void;
|
||||
isRestreamed: boolean;
|
||||
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
supportsAudioOutput: boolean;
|
||||
supports2WayTalk: boolean;
|
||||
};
|
||||
function FrigateCameraFeatures({
|
||||
camera,
|
||||
@@ -829,14 +920,124 @@ function FrigateCameraFeatures({
|
||||
audioDetectEnabled,
|
||||
autotrackingEnabled,
|
||||
fullscreen,
|
||||
streamName,
|
||||
setStreamName,
|
||||
preferredLiveMode,
|
||||
playInBackground,
|
||||
setPlayInBackground,
|
||||
showStats,
|
||||
setShowStats,
|
||||
isRestreamed,
|
||||
setLowBandwidth,
|
||||
supportsAudioOutput,
|
||||
supports2WayTalk,
|
||||
}: FrigateCameraFeaturesProps) {
|
||||
const { payload: detectState, send: sendDetect } = useDetectState(camera);
|
||||
const { payload: recordState, send: sendRecord } = useRecordingsState(camera);
|
||||
const { payload: snapshotState, send: sendSnapshot } =
|
||||
useSnapshotsState(camera);
|
||||
const { payload: audioState, send: sendAudio } = useAudioState(camera);
|
||||
const { payload: detectState, send: sendDetect } = useDetectState(
|
||||
camera.name,
|
||||
);
|
||||
const { payload: recordState, send: sendRecord } = useRecordingsState(
|
||||
camera.name,
|
||||
);
|
||||
const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState(
|
||||
camera.name,
|
||||
);
|
||||
const { payload: audioState, send: sendAudio } = useAudioState(camera.name);
|
||||
const { payload: autotrackingState, send: sendAutotracking } =
|
||||
useAutotrackingState(camera);
|
||||
useAutotrackingState(camera.name);
|
||||
|
||||
// manual event
|
||||
|
||||
const recordingEventIdRef = useRef<string | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [activeToastId, setActiveToastId] = useState<string | number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const createEvent = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`events/${camera.name}/on_demand/create`,
|
||||
{
|
||||
include_recording: true,
|
||||
duration: null,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
recordingEventIdRef.current = response.data.event_id;
|
||||
setIsRecording(true);
|
||||
const toastId = toast.success(
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="font-semibold">
|
||||
Started manual on-demand recording.
|
||||
</div>
|
||||
{!camera.record.enabled || camera.record.retain.days == 0 ? (
|
||||
<div>
|
||||
Since recording is disabled or restricted in the config for this
|
||||
camera, only a snapshot will be saved.
|
||||
</div>
|
||||
) : (
|
||||
<OnDemandRetentionMessage camera={camera} />
|
||||
)}
|
||||
</div>,
|
||||
{
|
||||
position: "top-center",
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
setActiveToastId(toastId);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to start manual on-demand recording.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [camera]);
|
||||
|
||||
const endEvent = useCallback(() => {
|
||||
if (activeToastId) {
|
||||
toast.dismiss(activeToastId);
|
||||
}
|
||||
try {
|
||||
if (recordingEventIdRef.current) {
|
||||
axios.put(`events/${recordingEventIdRef.current}/end`, {
|
||||
end_time: Math.ceil(Date.now() / 1000),
|
||||
});
|
||||
recordingEventIdRef.current = null;
|
||||
setIsRecording(false);
|
||||
toast.success("Ended manual on-demand recording.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to end manual on-demand recording.", {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [activeToastId]);
|
||||
|
||||
const handleEventButtonClick = useCallback(() => {
|
||||
if (isRecording) {
|
||||
endEvent();
|
||||
} else {
|
||||
createEvent();
|
||||
}
|
||||
}, [createEvent, endEvent, isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
// ensure manual event is stopped when component unmounts
|
||||
return () => {
|
||||
if (recordingEventIdRef.current) {
|
||||
endEvent();
|
||||
}
|
||||
};
|
||||
// mount/unmount only
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// navigate for debug view
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// desktop shows icons part of row
|
||||
if (isDesktop || isTablet) {
|
||||
@@ -888,6 +1089,264 @@ function FrigateCameraFeatures({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<CameraFeatureToggle
|
||||
className={cn(
|
||||
"p-2 md:p-0",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
|
||||
isActive={isRecording}
|
||||
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
|
||||
onClick={handleEventButtonClick}
|
||||
/>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0",
|
||||
)}
|
||||
>
|
||||
<FaCog
|
||||
className={`text-secondary-foreground" size-5 md:m-[6px]`}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-96">
|
||||
<div className="flex flex-col gap-5 p-4">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Stream</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Restreaming is not enabled for this camera.</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Set up go2rtc for additional live view options and audio
|
||||
for this camera.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed &&
|
||||
Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="streaming-method">Stream</Label>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{preferredLiveMode != "jsmpeg" && isRestreamed && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Audio is available for this stream</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Audio is unavailable for this stream</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Audio must be output from your camera and
|
||||
configured in go2rtc for this stream.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>
|
||||
Two-way talk is available for this stream
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>
|
||||
Two-way talk is unavailable for this stream
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Your device must suppport the feature and
|
||||
WebRTC must be configured for two-way talk.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
Live view is in low-bandwidth mode due to buffering
|
||||
or stream errors.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label="Reset the stream"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">
|
||||
Reset stream
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="backgroundplay"
|
||||
>
|
||||
Play in background
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="backgroundplay"
|
||||
checked={playInBackground}
|
||||
onCheckedChange={(checked) =>
|
||||
setPlayInBackground(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable this option to continue streaming when the player is
|
||||
hidden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-0 cursor-pointer text-primary"
|
||||
htmlFor="showstats"
|
||||
>
|
||||
Show stream stats
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="showstats"
|
||||
checked={showStats}
|
||||
onCheckedChange={(checked) => setShowStats(checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable this option to show stream statistics as an overlay on
|
||||
the camera feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-sm font-medium leading-none">
|
||||
Debug View
|
||||
<LuExternalLink
|
||||
onClick={() =>
|
||||
navigate(`/settings?page=debug&camera=${camera.name}`)
|
||||
}
|
||||
className="ml-2 inline-flex size-5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -908,44 +1367,276 @@ function FrigateCameraFeatures({
|
||||
title={`${camera} Settings`}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="flex flex-col gap-3 rounded-2xl px-2 py-4">
|
||||
<FilterSwitch
|
||||
label="Object Detection"
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<DrawerContent className="rounded-2xl px-2 py-4">
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Recording"
|
||||
isChecked={recordState == "ON"}
|
||||
label="Object Detection"
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
sendDetect(detectState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label="Snapshots"
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
{audioDetectEnabled && (
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Recording"
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label="Audio Detection"
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Autotracking"
|
||||
isChecked={autotrackingState == "ON"}
|
||||
label="Snapshots"
|
||||
isChecked={snapshotState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{audioDetectEnabled && (
|
||||
<FilterSwitch
|
||||
label="Audio Detection"
|
||||
isChecked={audioState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAudio(audioState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{autotrackingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Autotracking"
|
||||
isChecked={autotrackingState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
{!isRestreamed && (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Label>Stream</Label>
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Restreaming is not enabled for this camera.</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-xs">
|
||||
Set up go2rtc for additional live view options and audio for
|
||||
this camera.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
|
||||
<div className="mt-1 p-2">
|
||||
<div className="mb-1 text-sm">Stream</div>
|
||||
<Select
|
||||
value={streamName}
|
||||
onValueChange={(value) => {
|
||||
setStreamName?.(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{Object.keys(camera.live.streams).find(
|
||||
(key) => camera.live.streams[key] === streamName,
|
||||
)}
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(camera.live.streams).map(
|
||||
([stream, name]) => (
|
||||
<SelectItem
|
||||
key={stream}
|
||||
className="cursor-pointer"
|
||||
value={name}
|
||||
>
|
||||
{stream}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{preferredLiveMode != "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-1 flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supportsAudioOutput ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Audio is available for this stream</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Audio is unavailable for this stream</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
Audio must be output from your camera and configured
|
||||
in go2rtc for this stream.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode != "jsmpeg" &&
|
||||
isRestreamed &&
|
||||
supportsAudioOutput && (
|
||||
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
|
||||
{supports2WayTalk ? (
|
||||
<>
|
||||
<LuCheck className="size-4 text-success" />
|
||||
<div>Two-way talk is available for this stream</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LuX className="size-4 text-danger" />
|
||||
<div>Two-way talk is unavailable for this stream</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">Info</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 text-xs">
|
||||
Your device must suppport the feature and WebRTC
|
||||
must be configured for two-way talk.
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
Read the documentation{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preferredLiveMode == "jsmpeg" && isRestreamed && (
|
||||
<div className="mt-2 flex flex-col items-center gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IoIosWarning className="mr-1 size-8 text-danger" />
|
||||
|
||||
<p className="text-sm">
|
||||
Live view is in low-bandwidth mode due to buffering or
|
||||
stream errors.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={`flex items-center gap-2.5 rounded-lg`}
|
||||
aria-label="Reset the stream"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLowBandwidth(false)}
|
||||
>
|
||||
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
|
||||
<div className="text-primary-variant">Reset stream</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<div className="mb-1 text-sm font-medium leading-none">
|
||||
On-Demand Recording
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleEventButtonClick}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
|
||||
)}
|
||||
>
|
||||
{isRecording ? "End" : "Start"} on-demand recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a manual event based on this camera's recording retention
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
{isRestreamed && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Play in Background"
|
||||
isChecked={playInBackground}
|
||||
onCheckedChange={(checked) => {
|
||||
setPlayInBackground(checked);
|
||||
}}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
Enable this option to continue streaming when the player is
|
||||
hidden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FilterSwitch
|
||||
label="Show Stats"
|
||||
isChecked={showStats}
|
||||
onCheckedChange={(checked) => {
|
||||
setShowStats(checked);
|
||||
}}
|
||||
/>
|
||||
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
|
||||
Enable this option to show stream statistics as an overlay on
|
||||
the camera feed.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mb-3 flex flex-col gap-1 px-2">
|
||||
<div className="flex items-center justify-between text-sm font-medium leading-none">
|
||||
Debug View
|
||||
<LuExternalLink
|
||||
onClick={() =>
|
||||
navigate(`/settings?page=debug&camera=${camera.name}`)
|
||||
}
|
||||
className="ml-2 inline-flex size-5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -28,10 +28,16 @@ import DraggableGridLayout from "./DraggableGridLayout";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LivePlayerError } from "@/types/live";
|
||||
import {
|
||||
AudioState,
|
||||
LivePlayerError,
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
|
||||
type LiveDashboardViewProps = {
|
||||
cameras: CameraConfig[];
|
||||
@@ -184,8 +190,13 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
|
||||
useCameraLiveMode(cameras, windowVisible);
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const cameraRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
@@ -221,9 +232,45 @@ export default function LiveDashboardView({
|
||||
[setPreferredLiveModes],
|
||||
);
|
||||
|
||||
// audio states
|
||||
|
||||
const [audioStates, setAudioStates] = useState<AudioState>({});
|
||||
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
|
||||
const [statsStates, setStatsStates] = useState<StatsState>({});
|
||||
|
||||
const toggleStats = (cameraName: string): void => {
|
||||
setStatsStates((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: !prev[cameraName],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAudio = (cameraName: string): void => {
|
||||
setAudioStates((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: !prev[cameraName],
|
||||
}));
|
||||
};
|
||||
|
||||
const muteAll = (): void => {
|
||||
const updatedStates: Record<string, boolean> = {};
|
||||
visibleCameras.forEach((cameraName) => {
|
||||
updatedStates[cameraName] = false;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
};
|
||||
|
||||
const unmuteAll = (): void => {
|
||||
const updatedStates: Record<string, boolean> = {};
|
||||
visibleCameras.forEach((cameraName) => {
|
||||
updatedStates[cameraName] = true;
|
||||
});
|
||||
setAudioStates(updatedStates);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scrollbar-container size-full overflow-y-auto px-1 pt-2 md:p-2"
|
||||
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
|
||||
ref={containerRef}
|
||||
>
|
||||
{isMobile && (
|
||||
@@ -346,20 +393,56 @@ export default function LiveDashboardView({
|
||||
grow = "aspect-video";
|
||||
}
|
||||
return (
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
<LiveContextMenu
|
||||
className={grow}
|
||||
key={camera.name}
|
||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
cameraConfig={camera}
|
||||
camera={camera.name}
|
||||
cameraGroup={cameraGroup}
|
||||
streamName={Object.values(camera.live.streams)?.[0]}
|
||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
||||
autoLive={autoLiveView}
|
||||
onClick={() => onSelectCamera(camera.name)}
|
||||
onError={(e) => handleError(camera.name, e)}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
/>
|
||||
isRestreamed={isRestreamedStates[camera.name]}
|
||||
supportsAudio={
|
||||
supportsAudioOutputStates[
|
||||
Object.values(camera.live.streams)?.[0]
|
||||
]?.supportsAudio ?? false
|
||||
}
|
||||
audioState={audioStates[camera.name]}
|
||||
toggleAudio={() => toggleAudio(camera.name)}
|
||||
statsState={statsStates[camera.name]}
|
||||
toggleStats={() => toggleStats(camera.name)}
|
||||
volumeState={volumeStates[camera.name] ?? 1}
|
||||
setVolumeState={(value) =>
|
||||
setVolumeStates({
|
||||
[camera.name]: value,
|
||||
})
|
||||
}
|
||||
muteAll={muteAll}
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={() =>
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
>
|
||||
<LivePlayer
|
||||
cameraRef={cameraRef}
|
||||
key={camera.name}
|
||||
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
|
||||
windowVisible={
|
||||
windowVisible && visibleCameras.includes(camera.name)
|
||||
}
|
||||
cameraConfig={camera}
|
||||
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
||||
autoLive={autoLiveView}
|
||||
useWebGL={false}
|
||||
playInBackground={false}
|
||||
showStats={statsStates[camera.name]}
|
||||
streamName={Object.values(camera.live.streams)[0]}
|
||||
onClick={() => onSelectCamera(camera.name)}
|
||||
onError={(e) => handleError(camera.name, e)}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
playAudio={audioStates[camera.name] ?? false}
|
||||
volume={volumeStates[camera.name]}
|
||||
/>
|
||||
</LiveContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,25 @@ export default function UiSettingsView() {
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const clearStreamingSettings = useCallback(async () => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await delData(`streaming-settings`)
|
||||
.then(() => {
|
||||
toast.success(`Cleared streaming settings for all camera groups.`, {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
`Failed to clear camera groups streaming settings: ${error.response.data.message}`,
|
||||
{ position: "top-center" },
|
||||
);
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "General Settings - Frigate";
|
||||
}, []);
|
||||
@@ -84,11 +103,15 @@ export default function UiSettingsView() {
|
||||
Automatic Live View
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
Automatically switch to a camera's live view when activity is
|
||||
detected. Disabling this option causes static camera images on
|
||||
the Live dashboard to only update once per minute.
|
||||
the your dashboards to only update once per minute.{" "}
|
||||
<em>
|
||||
This is a global setting but can be overridden on each
|
||||
camera <strong>in camera groups only</strong>.
|
||||
</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +126,7 @@ export default function UiSettingsView() {
|
||||
Play Alert Videos
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
By default, recent alerts on the Live dashboard play as small
|
||||
looping videos. Disable this option to only show a static
|
||||
@@ -114,10 +137,10 @@ export default function UiSettingsView() {
|
||||
</div>
|
||||
|
||||
<div className="my-3 flex w-full flex-col space-y-6">
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Stored Layouts</div>
|
||||
<div className="my-2 text-sm text-muted-foreground">
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
The layout of cameras in a camera group can be
|
||||
dragged/resized. The positions are stored in your browser's
|
||||
@@ -133,6 +156,24 @@ export default function UiSettingsView() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">Camera Group Streaming Settings</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>
|
||||
Streaming settings for each camera group are stored in your
|
||||
browser's local storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Clear all group streaming settings"
|
||||
onClick={clearStreamingSettings}
|
||||
>
|
||||
Clear All Streaming Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
|
||||
Reference in New Issue
Block a user