diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index a006de8fd..374969787 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -1083,22 +1083,6 @@ ui: # Optional: Set the time format used. # Options are browser, 12hour, or 24hour (default: shown below) time_format: browser - # Optional: Set the date style for a specified length. - # Options are: full, long, medium, short - # Examples: - # short: 2/11/23 - # medium: Feb 11, 2023 - # full: Saturday, February 11, 2023 - # (default: shown below). - date_style: short - # Optional: Set the time style for a specified length. - # Options are: full, long, medium, short - # Examples: - # short: 8:14 PM - # medium: 8:15:22 PM - # full: 8:15:22 PM Mountain Standard Time - # (default: shown below). - time_style: medium # Optional: Set the unit system to either "imperial" or "metric" (default: metric) # Used in the UI and in MQTT topics unit_system: metric diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index fe2bee647..01092d4f1 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -146,7 +146,7 @@ class CameraConfig(FrigateBaseModel): timestamp_style: TimestampStyleConfig = Field( default_factory=TimestampStyleConfig, title="Timestamp style", - description="Styling options for in-feed timestamps applied to recordings and snapshots.", + description="Styling options for timestamps applied to snapshots and Debug view.", ) # Options without global fallback diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 2426fcf10..8c20c6e6d 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -45,7 +45,7 @@ class ProxyConfig(FrigateBaseModel): default_role: Optional[str] = Field( default="viewer", title="Default role", - description="Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer).", + description="Default role assigned to proxy-authenticated users when no role mapping applies.", ) separator: Optional[str] = Field( default=",", diff --git a/frigate/config/ui.py b/frigate/config/ui.py index 2c3104bbc..057a2b333 100644 --- a/frigate/config/ui.py +++ b/frigate/config/ui.py @@ -5,7 +5,7 @@ from pydantic import Field from .base import FrigateBaseModel -__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"] +__all__ = ["TimeFormatEnum", "UnitSystemEnum", "UIConfig"] class TimeFormatEnum(str, Enum): @@ -14,13 +14,6 @@ class TimeFormatEnum(str, Enum): hours24 = "24hour" -class DateTimeStyleEnum(str, Enum): - full = "full" - long = "long" - medium = "medium" - short = "short" - - class UnitSystemEnum(str, Enum): imperial = "imperial" metric = "metric" @@ -37,16 +30,6 @@ class UIConfig(FrigateBaseModel): title="Time format", description="Time format to use in the UI (browser, 12hour, or 24hour).", ) - date_style: DateTimeStyleEnum = Field( - default=DateTimeStyleEnum.short, - title="Date style", - description="Date style to use in the UI (full, long, medium, short).", - ) - time_style: DateTimeStyleEnum = Field( - default=DateTimeStyleEnum.medium, - title="Time style", - description="Time style to use in the UI (full, long, medium, short).", - ) unit_system: UnitSystemEnum = Field( default=UnitSystemEnum.metric, title="Unit system", diff --git a/frigate/util/config.py b/frigate/util/config.py index 431c8bff5..186730dbf 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -618,6 +618,16 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] new_config["cameras"][name] = camera_config + # Remove deprecated date_style and time_style from global ui config + global_ui = new_config.get("ui", {}) + if global_ui.get("date_style") is not None: + del new_config["ui"]["date_style"] + if global_ui.get("time_style") is not None: + del new_config["ui"]["time_style"] + # Remove ui section if empty + if "ui" in new_config and not new_config["ui"]: + del new_config["ui"] + new_config["version"] = "0.18-0" return new_config diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index 4f2c0ea01..fda78c6ab 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -682,7 +682,7 @@ }, "timestamp_style": { "label": "Timestamp style", - "description": "Styling options for in-feed timestamps applied to recordings and snapshots.", + "description": "Styling options for timestamps applied to snapshots and Debug view.", "position": { "label": "Timestamp position", "description": "Position of the timestamp on the image (tl/tr/bl/br)." diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 1f5c39248..c901aba0b 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -212,7 +212,7 @@ }, "default_role": { "label": "Default role", - "description": "Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer)." + "description": "Default role assigned to proxy-authenticated users when no role mapping applies." }, "separator": { "label": "Separator character", @@ -270,14 +270,6 @@ "label": "Time format", "description": "Time format to use in the UI (browser, 12hour, or 24hour)." }, - "date_style": { - "label": "Date style", - "description": "Date style to use in the UI (full, long, medium, short)." - }, - "time_style": { - "label": "Time style", - "description": "Time style to use in the UI (full, long, medium, short)." - }, "unit_system": { "label": "Unit system", "description": "Unit system for display (metric or imperial) used in the UI and MQTT." diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a19b70cec..6d52ae154 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1154,7 +1154,8 @@ }, "notificationUnavailable": { "title": "Notifications Unavailable", - "desc": "Web push notifications require a secure context (https://…). This is a browser limitation. Access Frigate securely to use notifications." + "desc": "Web push notifications require a secure context (https://…). This is a browser limitation. Access Frigate securely to use notifications.", + "descPwa": "On iOS, web push notifications are only available when Frigate is installed to your Home Screen. Open the Share menu, choose Add to Home Screen, then open Frigate from the new icon to register this device for notifications." }, "globalSettings": { "title": "Global Settings", @@ -1674,6 +1675,17 @@ "refresh": "Refresh models", "probeFailed": "Failed to probe models", "fetchedModels": "Successfully fetched model list" + }, + "ptzPresets": { + "placeholder": "Select or enter a preset...", + "search": "Search or enter a preset...", + "noPresets": "No presets available", + "available": "Camera presets", + "useCustom": "Use \"{{value}}\"" + }, + "defaultRole": { + "admin": "Admin", + "viewer": "Viewer" } }, "globalConfig": { @@ -1763,7 +1775,7 @@ "addStream": "Add stream", "addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.", "addUrl": "Add URL", - "streamNumber": "Stream {{index}}", + "sourceNumber": "Source {{index}}", "streamName": "Stream name", "streamNamePlaceholder": "e.g., front_door", "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", @@ -1840,12 +1852,6 @@ "12hour": "12 hour", "24hour": "24 hour" }, - "TimeOrDateStyle": { - "full": "Full", - "long": "Long", - "medium": "Medium", - "short": "Short" - }, "unitSystem": { "metric": "Metric", "imperial": "Imperial" @@ -1928,6 +1934,9 @@ }, "semanticSearch": { "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended." + }, + "onvif": { + "autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below." } } } diff --git a/web/src/components/classification/wizard/Step2StateArea.tsx b/web/src/components/classification/wizard/Step2StateArea.tsx index 7da1a62dc..e768b1d97 100644 --- a/web/src/components/classification/wizard/Step2StateArea.tsx +++ b/web/src/components/classification/wizard/Step2StateArea.tsx @@ -51,6 +51,7 @@ export default function Step2StateArea({ const [imageLoaded, setImageLoaded] = useState(false); const containerRef = useRef(null); + const popoverContainerRef = useRef(null); const imageRef = useRef(null); const stageRef = useRef(null); const rectRef = useRef(null); @@ -224,7 +225,7 @@ export default function Step2StateArea({ const canContinue = cameraAreas.length > 0; return ( -
+
e.preventDefault()} >
diff --git a/web/src/components/config-form/section-configs/onvif.ts b/web/src/components/config-form/section-configs/onvif.ts index edb62ff7f..a031c992c 100644 --- a/web/src/components/config-form/section-configs/onvif.ts +++ b/web/src/components/config-form/section-configs/onvif.ts @@ -25,6 +25,24 @@ const onvif: SectionConfigOverrides = { advancedFields: ["tls_insecure", "ignore_time_mismatch"], overrideFields: [], restartRequired: ["autotracking.calibrate_on_startup"], + fieldMessages: [ + { + key: "autotracking-no-zones", + field: "autotracking.required_zones", + messageKey: "configMessages.onvif.autotrackingNoZones", + severity: "error", + position: "before", + condition: (ctx) => { + if (ctx.level !== "camera") return false; + const zones = ctx.fullCameraConfig?.zones; + return ( + !zones || + typeof zones !== "object" || + Object.keys(zones).length === 0 + ); + }, + }, + ], uiSchema: { host: { "ui:options": { size: "sm" }, @@ -39,11 +57,16 @@ const onvif: SectionConfigOverrides = { required_zones: { "ui:widget": "zoneNames", }, + return_preset: { + "ui:options": { size: "sm" }, + "ui:widget": "ptzPresets", + }, track: { "ui:widget": "objectLabels", }, zooming: { "ui:options": { + size: "xs", enumI18nPrefix: "onvif.autotracking.zooming", }, }, diff --git a/web/src/components/config-form/section-configs/proxy.ts b/web/src/components/config-form/section-configs/proxy.ts index 08e05b6b8..897aef63e 100644 --- a/web/src/components/config-form/section-configs/proxy.ts +++ b/web/src/components/config-form/section-configs/proxy.ts @@ -21,6 +21,10 @@ const proxy: SectionConfigOverrides = { "ui:widget": "password", "ui:options": { size: "md" }, }, + default_role: { + "ui:widget": "defaultRole", + "ui:options": { size: "sm" }, + }, header_map: { "ui:after": { render: "ProxyRoleMap" }, }, diff --git a/web/src/components/config-form/section-configs/ui.ts b/web/src/components/config-form/section-configs/ui.ts index d6a477f5b..b9c7bfc8d 100644 --- a/web/src/components/config-form/section-configs/ui.ts +++ b/web/src/components/config-form/section-configs/ui.ts @@ -10,13 +10,7 @@ const ui: SectionConfigOverrides = { overrideFields: [], }, global: { - fieldOrder: [ - "timezone", - "time_format", - "date_style", - "time_style", - "unit_system", - ], + fieldOrder: ["timezone", "time_format", "unit_system"], advancedFields: [], restartRequired: ["unit_system"], uiSchema: { @@ -26,12 +20,6 @@ const ui: SectionConfigOverrides = { time_format: { "ui:options": { enumI18nPrefix: "ui.timeFormat" }, }, - date_style: { - "ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" }, - }, - time_style: { - "ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" }, - }, unit_system: { "ui:options": { enumI18nPrefix: "ui.unitSystem" }, }, diff --git a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx index cf8b6778c..337f37f23 100644 --- a/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx +++ b/web/src/components/config-form/sectionExtras/NotificationsSettingsExtras.tsx @@ -48,6 +48,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { isPWA } from "@/utils/isPWA"; +import { isIOS } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { cn } from "@/lib/utils"; @@ -437,6 +439,12 @@ export default function NotificationsSettingsExtras({ } if (!("Notification" in window) || !window.isSecureContext) { + // iOS only exposes web push to apps installed to the Home Screen, so a + // secure-context iOS browser tab that isn't an installed PWA has no + // Notification API. Android supports web push in a normal tab, so it never + // reaches this case and keeps the generic secure-context message. + const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA; + return (
@@ -465,12 +473,21 @@ export default function NotificationsSettingsExtras({ {t("notification.notificationUnavailable.title")} - - notification.notificationUnavailable.desc - +
, diff --git a/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx new file mode 100644 index 000000000..a8925784a --- /dev/null +++ b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import type { WidgetProps } from "@rjsf/utils"; +import { useTranslation } from "react-i18next"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ConfigFormContext } from "@/types/configForm"; +import { getSizedFieldClassName } from "../utils"; + +const BUILT_IN_ROLES = ["admin", "viewer"]; + +export function DefaultRoleWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, schema, options, registry } = + props; + const { t } = useTranslation(["views/settings"]); + + const fieldClassName = getSizedFieldClassName(options, "sm"); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const roles = useMemo(() => { + const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {}); + // Keep admin/viewer first, then any custom roles in config order. + const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r)); + return [...BUILT_IN_ROLES, ...custom]; + }, [formContext]); + + const selectedValue = typeof value === "string" && value ? value : "viewer"; + + const getLabel = (role: string) => + BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role; + + return ( + + ); +} + +export default DefaultRoleWidget; diff --git a/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx b/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx new file mode 100644 index 000000000..95e323b8a --- /dev/null +++ b/web/src/components/config-form/theme/widgets/PTZPresetsWidget.tsx @@ -0,0 +1,151 @@ +// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset). +// Fetches the camera's PTZ presets and shows them in a dropdown, while still +// allowing a typed custom value so existing presets that the camera does not +// report (such as "home") are preserved. +import { useState, useMemo } from "react"; +import type { WidgetProps } from "@rjsf/utils"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { ConfigFormContext } from "@/types/configForm"; +import type { CameraPtzInfo } from "@/types/ptz"; +import { getSizedFieldClassName } from "../utils"; + +export function PTZPresetsWidget(props: WidgetProps) { + const { id, value, disabled, readonly, onChange, options, registry } = props; + const { t } = useTranslation(["views/settings"]); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const fieldClassName = getSizedFieldClassName(options, "md"); + + const formContext = registry?.formContext as ConfigFormContext | undefined; + const cameraName = formContext?.cameraName; + const isCameraLevel = formContext?.level === "camera"; + const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host; + + const { data: ptzInfo } = useSWR( + isCameraLevel && cameraName && hasOnvifHost + ? `${cameraName}/ptz/info` + : null, + { + // ONVIF may not be initialized yet when the settings page loads, + // so retry until presets become available + refreshInterval: (data) => + data?.presets && data.presets.length > 0 ? 0 : 5000, + }, + ); + + const presets = useMemo(() => ptzInfo?.presets ?? [], [ptzInfo]); + + const trimmedSearch = searchValue.trim(); + const matchesPreset = useMemo( + () => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()), + [presets, trimmedSearch], + ); + const showCustomOption = trimmedSearch.length > 0 && !matchesPreset; + + const commit = (next: string) => { + onChange(next); + setSearchValue(""); + setOpen(false); + }; + + const currentLabel = typeof value === "string" && value ? value : undefined; + + return ( + { + setOpen(next); + if (!next) setSearchValue(""); + }} + > + + + + + + { + if (e.key === "Enter" && showCustomOption) { + e.preventDefault(); + commit(trimmedSearch); + } + }} + /> + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + {t("configForm.ptzPresets.useCustom", { + value: trimmedSearch, + })} + + + )} + {presets.length > 0 ? ( + + {presets.map((preset) => ( + commit(preset)} + > + + {preset} + + ))} + + ) : !showCustomOption ? ( +
+ {t("configForm.ptzPresets.noPresets")} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 68a282220..8c229410e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -4,8 +4,6 @@ import { TriggerAction, TriggerType } from "./trigger"; export interface UiConfig { timezone?: string; time_format?: "browser" | "12hour" | "24hour"; - date_style?: "full" | "long" | "medium" | "short"; - time_style?: "full" | "long" | "medium" | "short"; dashboard: boolean; order: number; unit_system?: "metric" | "imperial"; diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx index f1bf2b360..b9277f3d8 100644 --- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -902,7 +902,7 @@ function StreamUrlEntry({ return (
- {t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })} + {t("go2rtcStreams.sourceNumber", { index: urlIndex + 1 })} {canRemove && (