-
+
{loading ? (
) : (
)}
@@ -780,7 +814,7 @@ function DetectionReview({
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
- segmentDuration={segmentDuration}
+ segmentDuration={zoomSettings.segmentDuration}
events={reviewItems?.all ?? []}
severityType={severity}
/>
@@ -1095,7 +1129,6 @@ function MotionReview({
setHandlebarTime={setCurrentTime}
events={reviewItems?.all ?? []}
motion_events={motionData ?? []}
- severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) {
@@ -1105,6 +1138,8 @@ function MotionReview({
setScrubbing(scrubbing);
}}
dense={isMobileOnly}
+ isZooming={false}
+ zoomDirection={null}
/>
) : (
diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx
index fc2d9bb52..131f44770 100644
--- a/web/src/views/live/DraggableGridLayout.tsx
+++ b/web/src/views/live/DraggableGridLayout.tsx
@@ -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
({});
+ const [volumeStates, setVolumeStates] = useState({});
+ const [statsStates, setStatsStates] = useState(() => {
+ 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 (
<>
@@ -364,7 +489,7 @@ export default function DraggableGridLayout({
) : (
{
- !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)
+ }
>
+ {
+ !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 && }
-
+
);
})}
@@ -596,41 +768,57 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
},
);
-type LivePlayerGridItemProps = {
+type GridLiveContextMenuProps = {
+ className?: string;
style?: React.CSSProperties;
- className: string;
onMouseDown?: React.MouseEventHandler;
onMouseUp?: React.MouseEventHandler;
onTouchEnd?: React.TouchEventHandler;
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}
>
- }
- />
- {children}
+ isRestreamed={isRestreamed}
+ supportsAudio={supportsAudio}
+ audioState={audioState}
+ toggleAudio={toggleAudio}
+ statsState={statsState}
+ toggleStats={toggleStats}
+ volumeState={volumeState}
+ setVolumeState={setVolumeState}
+ muteAll={muteAll}
+ unmuteAll={unmuteAll}
+ resetPreferredLiveMode={resetPreferredLiveMode}
+ >
+ {children}
+
);
},
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx
index af3ed0cee..ccf06de7b 100644
--- a/web/src/views/live/LiveCameraView.tsx
+++ b/web/src/views/live/LiveCameraView.tsx
@@ -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
(
+ `${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(
- 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(
+ `${camera.name}-background-play`,
+ false,
+ );
+
+ const [showStats, setShowStats] = useState(false);
+
const [fullResolution, setFullResolution] = useState({
width: 0,
height: 0,
@@ -337,6 +375,7 @@ export default function LiveCameraView({
return (
+
)}
@@ -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" ? (
+
+ ) : 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