diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index a006de8fd2..3749697876 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 fe2bee647f..01092d4f18 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 2426fcf104..8c20c6e6dd 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 2c3104bbc9..057a2b3336 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 431c8bff53..186730dbfb 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 4f2c0ea01e..fda78c6abc 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 1f5c39248c..c901aba0b6 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 a19b70cec4..6d52ae154b 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 7da1a62dc5..e768b1d970 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 edb62ff7fe..a031c992c0 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 08e05b6b86..897aef63e0 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 d6a477f5b2..b9c7bfc8d1 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 cf8b6778c6..337f37f23a 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 0000000000..a8925784a7 --- /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 0000000000..95e323b8ac --- /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 68a2822200..8c229410ee 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 f1bf2b360e..b9277f3d81 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 && (