From 7a7ab98888d5847c5d5ada27ef54a07f62f94045 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 20 Nov 2025 16:39:36 -0600
Subject: [PATCH] always show camera group buttons on mobile so users don't get
stuck
---
web/public/locales/en/views/live.json | 6 +-
web/src/views/live/LiveDashboardView.tsx | 419 ++++++++++++-----------
2 files changed, 231 insertions(+), 194 deletions(-)
diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
index 085aa0a49..21f367ea9 100644
--- a/web/public/locales/en/views/live.json
+++ b/web/public/locales/en/views/live.json
@@ -177,6 +177,10 @@
"noCameras": {
"title": "No Cameras Configured",
"description": "Get started by connecting a camera to Frigate.",
- "buttonText": "Add Camera"
+ "buttonText": "Add Camera",
+ "restricted": {
+ "title": "No Cameras Available",
+ "description": "You don't have permission to view any cameras in this group."
+ }
}
}
diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx
index c4104576c..c096e05ef 100644
--- a/web/src/views/live/LiveDashboardView.tsx
+++ b/web/src/views/live/LiveDashboardView.tsx
@@ -20,7 +20,14 @@ import {
FrigateConfig,
} from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import {
isDesktop,
isMobile,
@@ -46,6 +53,8 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
import { EmptyCard } from "@/components/card/EmptyCard";
import { BsFillCameraVideoOffFill } from "react-icons/bs";
+import { AuthContext } from "@/context/auth-context";
+import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@@ -374,10 +383,6 @@ export default function LiveDashboardView({
onSaveMuting(true);
};
- if (cameras.length == 0 && !includeBirdseye) {
- return ;
- }
-
return (
)}
- {!fullscreen && events && events.length > 0 && (
-
-
-
- {events.map((event) => {
- return (
-
- );
- })}
-
-
-
-
- )}
-
- {!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
+ {cameras.length == 0 && !includeBirdseye ? (
+
+ ) : (
<>
-
- {includeBirdseye && birdseyeConfig?.enabled && (
+ {!fullscreen && events && events.length > 0 && (
+
+
+
+ {events.map((event) => {
+ return (
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
+ <>
{
- const aspectRatio =
- birdseyeConfig.width / birdseyeConfig.height;
- if (aspectRatio > 2) {
- return `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
- } else if (aspectRatio < 1) {
- return `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
- } else {
- return "aspect-video";
- }
- })()}
- ref={birdseyeContainerRef}
+ className={cn(
+ "mt-2 grid grid-cols-1 gap-2 px-2 md:gap-4",
+ mobileLayout == "grid" &&
+ "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4",
+ isMobile && "px-0",
+ )}
>
- onSelectCamera("birdseye")}
- containerRef={birdseyeContainerRef}
- />
-
- )}
- {cameras.map((camera) => {
- let grow;
- const aspectRatio = camera.detect.width / camera.detect.height;
- if (aspectRatio > 2) {
- grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
- } else if (aspectRatio < 1) {
- grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
- } else {
- grow = "aspect-video";
- }
- const availableStreams = camera.live.streams || {};
- const firstStreamEntry = Object.values(availableStreams)[0] || "";
-
- const streamNameFromSettings =
- currentGroupStreamingSettings?.[camera.name]?.streamName || "";
- const streamExists =
- streamNameFromSettings &&
- Object.values(availableStreams).includes(
- streamNameFromSettings,
- );
-
- const streamName = streamExists
- ? streamNameFromSettings
- : firstStreamEntry;
- const streamType =
- currentGroupStreamingSettings?.[camera.name]?.streamType;
- const autoLive =
- streamType !== undefined
- ? streamType !== "no-streaming"
- : undefined;
- const showStillWithoutActivity =
- currentGroupStreamingSettings?.[camera.name]?.streamType !==
- "continuous";
- const useWebGL =
- currentGroupStreamingSettings?.[camera.name]
- ?.compatibilityMode || false;
- return (
-
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)
- }
- config={config}
- >
- onSelectCamera(camera.name)}
- onError={(e) => handleError(camera.name, e)}
- onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
- playAudio={audioStates[camera.name] ?? false}
- volume={volumeStates[camera.name]}
- />
-
- );
- })}
-
- {isDesktop && (
-
-
-
+ {includeBirdseye && birdseyeConfig?.enabled && (
{
+ const aspectRatio =
+ birdseyeConfig.width / birdseyeConfig.height;
+ if (aspectRatio > 2) {
+ return `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
+ } else if (aspectRatio < 1) {
+ return `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
+ } else {
+ return "aspect-video";
+ }
+ })()}
+ ref={birdseyeContainerRef}
>
- {fullscreen ? (
-
- ) : (
-
- )}
+ onSelectCamera("birdseye")}
+ containerRef={birdseyeContainerRef}
+ />
-
-
- {fullscreen
- ? t("button.exitFullscreen", { ns: "common" })
- : t("button.fullscreen", { ns: "common" })}
-
-
-
+ )}
+ {cameras.map((camera) => {
+ let grow;
+ const aspectRatio =
+ camera.detect.width / camera.detect.height;
+ if (aspectRatio > 2) {
+ grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
+ } else if (aspectRatio < 1) {
+ grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
+ } else {
+ grow = "aspect-video";
+ }
+ const availableStreams = camera.live.streams || {};
+ const firstStreamEntry =
+ Object.values(availableStreams)[0] || "";
+
+ const streamNameFromSettings =
+ currentGroupStreamingSettings?.[camera.name]?.streamName ||
+ "";
+ const streamExists =
+ streamNameFromSettings &&
+ Object.values(availableStreams).includes(
+ streamNameFromSettings,
+ );
+
+ const streamName = streamExists
+ ? streamNameFromSettings
+ : firstStreamEntry;
+ const streamType =
+ currentGroupStreamingSettings?.[camera.name]?.streamType;
+ const autoLive =
+ streamType !== undefined
+ ? streamType !== "no-streaming"
+ : undefined;
+ const showStillWithoutActivity =
+ currentGroupStreamingSettings?.[camera.name]?.streamType !==
+ "continuous";
+ const useWebGL =
+ currentGroupStreamingSettings?.[camera.name]
+ ?.compatibilityMode || false;
+ return (
+
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)
+ }
+ config={config}
+ >
+ onSelectCamera(camera.name)}
+ onError={(e) => handleError(camera.name, e)}
+ onResetLiveMode={() =>
+ resetPreferredLiveMode(camera.name)
+ }
+ playAudio={audioStates[camera.name] ?? false}
+ volume={volumeStates[camera.name]}
+ />
+
+ );
+ })}
+
+ {isDesktop && (
+
+
+
+
+ {fullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {fullscreen
+ ? t("button.exitFullscreen", { ns: "common" })
+ : t("button.fullscreen", { ns: "common" })}
+
+
+
+ )}
+ >
+ ) : (
+
)}
>
- ) : (
-
)}
);
@@ -638,15 +660,26 @@ export default function LiveDashboardView({
function NoCameraView() {
const { t } = useTranslation(["views/live"]);
+ const { auth } = useContext(AuthContext);
+ const isCustomRole = useIsCustomRole();
+
+ // Check if this is a restricted user with no cameras in this group
+ const isRestricted = isCustomRole && auth.isAuthenticated;
return (
}
- title={t("noCameras.title")}
- description={t("noCameras.description")}
- buttonText={t("noCameras.buttonText")}
- link="/settings?page=cameraManagement"
+ title={
+ isRestricted ? t("noCameras.restricted.title") : t("noCameras.title")
+ }
+ description={
+ isRestricted
+ ? t("noCameras.restricted.description")
+ : t("noCameras.description")
+ }
+ buttonText={!isRestricted ? t("noCameras.buttonText") : undefined}
+ link={!isRestricted ? "/settings?page=cameraManagement" : undefined}
/>
);