Compare commits

...

8 Commits

Author SHA1 Message Date
Blake Blackshear
acb0261f7b
Merge de066d0062 into 3620ef27db 2025-11-11 23:23:34 +00:00
GuoQing Liu
de066d0062
Fix i18n (#20857)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* fix: fix the missing i18n key

* fix: fix trackedObject i18n keys count variable

* fix: fix some pages audio label missing i18n

* fix: add 6214d52 missing variable

* fix: add more missing i18n

* fix: add menu missing key
2025-11-11 17:23:30 -06:00
Nicolas Mowen
f1a05d0f9b
Miscellaneous fixes (#20875)
* Improve stream fetching logic

* Reduce need to revalidate stream info

* fix frigate+ frame submission

* add UI setting to configure jsmpeg fallback timeout

* hide settings dropdown when fullscreen

* Fix arcface running on OpenVINO

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-11 17:00:54 -06:00
Nicolas Mowen
3620ef27db
Update hailo installation instructions (#20847)
* Update hailo docs installation

* Adjust section separation
2025-11-08 13:21:15 -06:00
GuoQing Liu
5cf2ae0121
docs: remove webrtc not support H.265 tips (#20769) 2025-11-05 06:23:45 -06:00
Nicolas Mowen
17d2bc240a
Update recommended hardware to list more models (#20777)
* Update recommended hardware to list more models

* Update hardware.md with new Intel models and links
2025-11-04 10:56:28 -06:00
Nicolas Mowen
6fd7f862f5
Update coral docs / links (#20674)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 5 (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Revise GPU and AI accelerator recommendations

Updated hardware recommendations for AI acceleration.

* Revise PCIe Coral driver installation instructions

Updated instructions for PCIe Coral driver installation.

* Revise Coral driver installation instructions

Updated driver installation instructions for PCIe and M.2 versions of Google Coral.

* Change PCIe Coral driver link in getting_started.md

Updated the link for PCIe Coral driver instructions.

* Change PCIe Coral driver link in installation guide

Updated the link for PCIe Coral driver instructions.

* Update Coral TPU recommendation in hardware documentation

Added a warning about the Coral TPU's recommendation status for new Frigate installations and suggested alternatives.
2025-10-26 06:56:01 -05:00
Nicolas Mowen
5d038b5c75
Update PWA requirements and add usage section (#20562)
Added VPN as a secure context option for PWA installation and included a usage section.
2025-10-26 05:39:09 -06:00
25 changed files with 623 additions and 325 deletions

View File

@ -12,7 +12,7 @@
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead. Use of a GPU, Integrated GPU, or AI accelerator such as a [Hailo](https://hailo.ai/) is highly recommended. Dedicated hardware will outperform even the best CPUs with very little overhead.
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary

View File

@ -15,7 +15,7 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt
| ------ | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| jsmpeg | same as `detect -> fps`, capped at 10 | 720p | no | no | Resolution is configurable, but go2rtc is recommended if you want higher resolutions and better frame rates. jsmpeg is Frigate's default without go2rtc configured. | | jsmpeg | same as `detect -> fps`, capped at 10 | 720p | no | no | Resolution is configurable, but go2rtc is recommended if you want higher resolutions and better frame rates. jsmpeg is Frigate's default without go2rtc configured. |
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. | | mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration, doesn't support h.265. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. | | webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
### Camera Settings Recommendations ### Camera Settings Recommendations
@ -127,7 +127,8 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
``` ```
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block. - For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
- Note that WebRTC does not support H.265.
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
:::tip :::tip

View File

@ -11,7 +11,7 @@ This adds features including the ability to deep link directly into the app.
In order to install Frigate as a PWA, the following requirements must be met: In order to install Frigate as a PWA, the following requirements must be met:
- Frigate must be accessed via a secure context (localhost, secure https, etc.) - Frigate must be accessed via a secure context (localhost, secure https, VPN, etc.)
- On Android, Firefox, Chrome, Edge, Opera, and Samsung Internet Browser all support installing PWAs. - On Android, Firefox, Chrome, Edge, Opera, and Samsung Internet Browser all support installing PWAs.
- On iOS 16.4 and later, PWAs can be installed from the Share menu in Safari, Chrome, Edge, Firefox, and Orion. - On iOS 16.4 and later, PWAs can be installed from the Share menu in Safari, Chrome, Edge, Firefox, and Orion.
@ -22,3 +22,7 @@ Installation varies slightly based on the device that is being used:
- Desktop: Use the install button typically found in right edge of the address bar - Desktop: Use the install button typically found in right edge of the address bar
- Android: Use the `Install as App` button in the more options menu for Chrome, and the `Add app to Home screen` button for Firefox - Android: Use the `Install as App` button in the more options menu for Chrome, and the `Add app to Home screen` button for Firefox
- iOS: Use the `Add to Homescreen` button in the share menu - iOS: Use the `Add to Homescreen` button in the share menu
## Usage
Once setup, the Frigate app can be used wherever it has access to Frigate. This means it can be setup as local-only, VPN-only, or fully accessible depending on your needs.

View File

@ -36,9 +36,11 @@ If the EQ13 is out of stock, the link below may take you to a suggested alternat
::: :::
| Name | Coral Inference Speed | Coral Compatibility | Notes | | Name | Capabilities | Notes |
| ------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | ----------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 5-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | | Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | Can run object detection on several 1080p cameras with low-medium activity | Dual gigabit NICs for easy isolated camera network. |
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP) | Can handle a large number of 1080p cameras with high activity | |
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
## Detectors ## Detectors
@ -129,10 +131,16 @@ In real-world deployments, even with multiple cameras running concurrently, Frig
### Google Coral TPU ### Google Coral TPU
:::warning
The Coral is no longer recommended for new Frigate installations, except in deployments with particularly low power requirements or hardware incapable of utilizing alternative AI accelerators for object detection. Instead, we suggest using one of the numerous other supported object detectors. Frigate will continue to provide support for the Coral TPU for as long as practicably possible given its still one of the most power-efficient devices for executing object detection models.
:::
Frigate supports both the USB and M.2 versions of the Google Coral. Frigate supports both the USB and M.2 versions of the Google Coral.
- The USB version is compatible with the widest variety of hardware and does not require a driver on the host machine. However, it does lack the automatic throttling features of the other versions. - The USB version is compatible with the widest variety of hardware and does not require a driver on the host machine. However, it does lack the automatic throttling features of the other versions.
- The PCIe and M.2 versions require installation of a driver on the host. Follow the instructions for your version from https://coral.ai - The PCIe and M.2 versions require installation of a driver on the host. https://github.com/jnicolson/gasket-builder should be used.
A single Coral can handle many cameras using the default model and will be sufficient for the majority of users. You can calculate the maximum performance of your Coral based on the inference speed reported by Frigate. With an inference speed of 10, your Coral will top out at `1000/10=100`, or 100 frames per second. If your detection fps is regularly getting close to that, you should first consider tuning motion masks. If those are already properly configured, a second Coral may be needed. A single Coral can handle many cameras using the default model and will be sufficient for the majority of users. You can calculate the maximum performance of your Coral based on the inference speed reported by Frigate. With an inference speed of 10, your Coral will top out at `1000/10=100`, or 100 frames per second. If your detection fps is regularly getting close to that, you should first consider tuning motion masks. If those are already properly configured, a second Coral may be needed.

View File

@ -94,6 +94,10 @@ $ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576
The shm size cannot be set per container for Home Assistant add-ons. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration. The shm size cannot be set per container for Home Assistant add-ons. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
## Extra Steps for Specific Hardware
The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section.
### Raspberry Pi 3/4 ### Raspberry Pi 3/4
By default, the Raspberry Pi limits the amount of memory available to the GPU. In order to use ffmpeg hardware acceleration, you must increase the available memory by setting `gpu_mem` to the maximum recommended value in `config.txt` as described in the [official docs](https://www.raspberrypi.org/documentation/computers/config_txt.html#memory-options). By default, the Raspberry Pi limits the amount of memory available to the GPU. In order to use ffmpeg hardware acceleration, you must increase the available memory by setting `gpu_mem` to the maximum recommended value in `config.txt` as described in the [official docs](https://www.raspberrypi.org/documentation/computers/config_txt.html#memory-options).
@ -106,14 +110,107 @@ The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form
#### Installation #### Installation
For Raspberry Pi 5 users with the AI Kit, installation is straightforward. Simply follow this [guide](https://www.raspberrypi.com/documentation/accessories/ai-kit.html#ai-kit-installation) to install the driver and software. :::warning
For other installations, follow these steps for installation: The Raspberry Pi kernel includes an older version of the Hailo driver that is incompatible with Frigate. You **must** follow the installation steps below to install the correct driver version, and you **must** disable the built-in kernel driver as described in step 1.
1. Install the driver from the [Hailo GitHub repository](https://github.com/hailo-ai/hailort-drivers). A convenient script for Linux is available to clone the repository, build the driver, and install it. :::
2. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/hailo8l/user_installation.sh).
3. Ensure it has execution permissions with `sudo chmod +x user_installation.sh` 1. **Disable the built-in Hailo driver (Raspberry Pi only)**:
4. Run the script with `./user_installation.sh`
:::note
If you are **not** using a Raspberry Pi, skip this step and proceed directly to step 2.
:::
If you are using a Raspberry Pi, you need to blacklist the built-in kernel Hailo driver to prevent conflicts. First, check if the driver is currently loaded:
```bash
lsmod | grep hailo
```
If it shows `hailo_pci`, unload it:
```bash
sudo rmmod hailo_pci
```
Now blacklist the driver to prevent it from loading on boot:
```bash
echo "blacklist hailo_pci" | sudo tee /etc/modprobe.d/blacklist-hailo_pci.conf
```
Update initramfs to ensure the blacklist takes effect:
```bash
sudo update-initramfs -u
```
Reboot your Raspberry Pi:
```bash
sudo reboot
```
After rebooting, verify the built-in driver is not loaded:
```bash
lsmod | grep hailo
```
This command should return no results. If it still shows `hailo_pci`, the blacklist did not take effect properly and you may need to check for other Hailo packages installed via apt that are loading the driver.
2. **Run the installation script**:
Download the installation script:
```bash
wget https://raw.githubusercontent.com/blakeblackshear/frigate/dev/docker/hailo8l/user_installation.sh
```
Make it executable:
```bash
sudo chmod +x user_installation.sh
```
Run the script:
```bash
./user_installation.sh
```
The script will:
- Install necessary build dependencies
- Clone and build the Hailo driver from the official repository
- Install the driver
- Download and install the required firmware
- Set up udev rules
3. **Reboot your system**:
After the script completes successfully, reboot to load the firmware:
```bash
sudo reboot
```
4. **Verify the installation**:
After rebooting, verify that the Hailo device is available:
```bash
ls -l /dev/hailo0
```
You should see the device listed. You can also verify the driver is loaded:
```bash
lsmod | grep hailo_pci
```
#### Setup #### Setup
@ -302,7 +399,7 @@ services:
shm_size: "512mb" # update for your cameras based on calculation above shm_size: "512mb" # update for your cameras based on calculation above
devices: devices:
- /dev/bus/usb:/dev/bus/usb # Passes the USB Coral, needs to be modified for other versions - /dev/bus/usb:/dev/bus/usb # Passes the USB Coral, needs to be modified for other versions
- /dev/apex_0:/dev/apex_0 # Passes a PCIe Coral, follow driver instructions here https://coral.ai/docs/m2/get-started/#2a-on-linux - /dev/apex_0:/dev/apex_0 # Passes a PCIe Coral, follow driver instructions here https://github.com/jnicolson/gasket-builder
- /dev/video11:/dev/video11 # For Raspberry Pi 4B - /dev/video11:/dev/video11 # For Raspberry Pi 4B
- /dev/dri/renderD128:/dev/dri/renderD128 # AMD / Intel GPU, needs to be updated for your hardware - /dev/dri/renderD128:/dev/dri/renderD128 # AMD / Intel GPU, needs to be updated for your hardware
- /dev/accel:/dev/accel # Intel NPU - /dev/accel:/dev/accel # Intel NPU

View File

@ -202,7 +202,7 @@ services:
... ...
devices: devices:
- /dev/bus/usb:/dev/bus/usb # passes the USB Coral, needs to be modified for other versions - /dev/bus/usb:/dev/bus/usb # passes the USB Coral, needs to be modified for other versions
- /dev/apex_0:/dev/apex_0 # passes a PCIe Coral, follow driver instructions here https://coral.ai/docs/m2/get-started/#2a-on-linux - /dev/apex_0:/dev/apex_0 # passes a PCIe Coral, follow driver instructions here https://github.com/jnicolson/gasket-builder
... ...
``` ```

View File

@ -68,8 +68,7 @@ The USB Coral can become stuck and need to be restarted, this can happen for a n
The most common reason for the PCIe Coral not being detected is that the driver has not been installed. This process varies based on what OS and kernel that is being run. The most common reason for the PCIe Coral not being detected is that the driver has not been installed. This process varies based on what OS and kernel that is being run.
- In most cases [the Coral docs](https://coral.ai/docs/m2/get-started/#2-install-the-pcie-driver-and-edge-tpu-runtime) show how to install the driver for the PCIe based Coral. - In most cases https://github.com/jnicolson/gasket-builder can be used to build and install the latest version of the driver.
- For some newer Linux distros (for example, Ubuntu 22.04+), https://github.com/jnicolson/gasket-builder can be used to build and install the latest version of the driver.
## Attempting to load TPU as pci & Fatal Python error: Illegal instruction ## Attempting to load TPU as pci & Fatal Python error: Illegal instruction

View File

@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
def __init__(self, model_path: str, device: str, model_type: str, **kwargs): def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path self.model_path = model_path
self.device = device self.device = device
self.model_type = model_type
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported( if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
model_type model_type
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
# Lock prevents concurrent access to infer_request # Lock prevents concurrent access to infer_request
# Needed for JinaV2: genai thread (text) + embeddings thread (vision) # Needed for JinaV2: genai thread (text) + embeddings thread (vision)
with self._inference_lock: with self._inference_lock:
from frigate.embeddings.types import EnrichmentModelTypeEnum
if self.model_type in [EnrichmentModelTypeEnum.arcface.value]:
# For face recognition models, create a fresh infer_request
# for each inference to avoid state pollution that causes incorrect results.
self.infer_request = self.compiled_model.create_infer_request()
# Handle single input case for backward compatibility # Handle single input case for backward compatibility
if ( if (
len(inputs) == 1 len(inputs) == 1

View File

@ -72,7 +72,10 @@
"formattedTimestampFilename": { "formattedTimestampFilename": {
"12hour": "MM-dd-yy-h-mm-ss-a", "12hour": "MM-dd-yy-h-mm-ss-a",
"24hour": "MM-dd-yy-HH-mm-ss" "24hour": "MM-dd-yy-HH-mm-ss"
} },
"inProgress": "In progress",
"invalidStartTime": "Invalid start time",
"invalidEndTime": "Invalid end time"
}, },
"unit": { "unit": {
"speed": { "speed": {
@ -144,7 +147,8 @@
"unselect": "Unselect", "unselect": "Unselect",
"export": "Export", "export": "Export",
"deleteNow": "Delete Now", "deleteNow": "Delete Now",
"next": "Next" "next": "Next",
"continue": "Continue"
}, },
"menu": { "menu": {
"system": "System", "system": "System",
@ -237,6 +241,7 @@
"export": "Export", "export": "Export",
"uiPlayground": "UI Playground", "uiPlayground": "UI Playground",
"faceLibrary": "Face Library", "faceLibrary": "Face Library",
"classification": "Classification",
"user": { "user": {
"title": "User", "title": "User",
"account": "Account", "account": "Account",

View File

@ -24,8 +24,8 @@
"label": "Detail", "label": "Detail",
"noDataFound": "No detail data to review", "noDataFound": "No detail data to review",
"aria": "Toggle detail view", "aria": "Toggle detail view",
"trackedObject_one": "object", "trackedObject_one": "{{count}} object",
"trackedObject_other": "objects", "trackedObject_other": "{{count}} objects",
"noObjectDetailData": "No object detail data available.", "noObjectDetailData": "No object detail data available.",
"settings": "Detail View Settings", "settings": "Detail View Settings",
"alwaysExpandActive": { "alwaysExpandActive": {

View File

@ -35,7 +35,7 @@
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail", "thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "tracking_details": "tracking details"
}, },
"trackingDetails": { "trackingDetails": {
"title": "Tracking Details", "title": "Tracking Details",

View File

@ -8,7 +8,7 @@
"masksAndZones": "Mask and Zone Editor - Frigate", "masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate", "motionTuner": "Motion Tuner - Frigate",
"object": "Debug - Frigate", "object": "Debug - Frigate",
"general": "General Settings - Frigate", "general": "UI Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate", "frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate" "notifications": "Notification Settings - Frigate"
}, },
@ -37,7 +37,7 @@
"noCamera": "No Camera" "noCamera": "No Camera"
}, },
"general": { "general": {
"title": "General Settings", "title": "UI Settings",
"liveDashboard": { "liveDashboard": {
"title": "Live Dashboard", "title": "Live Dashboard",
"automaticLiveView": { "automaticLiveView": {
@ -51,6 +51,10 @@
"displayCameraNames": { "displayCameraNames": {
"label": "Always Show Camera Names", "label": "Always Show Camera Names",
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard." "desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
},
"liveFallbackTimeout": {
"label": "Live Player Fallback Timeout",
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
} }
}, },
"storedLayouts": { "storedLayouts": {

View File

@ -454,6 +454,24 @@ export function GeneralFilterContent({
onClose, onClose,
}: GeneralFilterContentProps) { }: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter", "views/events"]); const { t } = useTranslation(["components/filter", "views/events"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allAudioListenLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config]);
return ( return (
<> <>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
@ -504,7 +522,10 @@ export function GeneralFilterContent({
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={getTranslatedLabel(item)} label={getTranslatedLabel(
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={filter.labels?.includes(item) ?? false} isChecked={filter.labels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -81,6 +81,43 @@ export default function InputWithTags({
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const allAudioListenLabels = useMemo<Set<string>>(() => {
if (!config) {
return new Set<string>();
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return labels;
}, [config]);
const translatedAudioLabelMap = useMemo<Map<string, string>>(() => {
const map = new Map<string, string>();
if (!config) return map;
allAudioListenLabels.forEach((label) => {
// getTranslatedLabel likely depends on i18n internally; including `lang`
// in deps ensures this map is rebuilt when language changes
map.set(label, getTranslatedLabel(label, "audio"));
});
return map;
}, [allAudioListenLabels, config]);
function resolveLabel(value: string) {
const mapped = translatedAudioLabelMap.get(value);
if (mapped) return mapped;
return getTranslatedLabel(
value,
allAudioListenLabels.has(value) ? "audio" : "object",
);
}
const [inputValue, setInputValue] = useState(search || ""); const [inputValue, setInputValue] = useState(search || "");
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>( const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
null, null,
@ -421,7 +458,8 @@ export default function InputWithTags({
? t("button.yes", { ns: "common" }) ? t("button.yes", { ns: "common" })
: t("button.no", { ns: "common" }); : t("button.no", { ns: "common" });
} else if (filterType === "labels") { } else if (filterType === "labels") {
return getTranslatedLabel(String(filterValues)); const value = String(filterValues);
return resolveLabel(value);
} else if (filterType === "search_type") { } else if (filterType === "search_type") {
return t("filter.searchType." + String(filterValues)); return t("filter.searchType." + String(filterValues));
} else { } else {
@ -828,7 +866,7 @@ export default function InputWithTags({
> >
{t("filter.label." + filterType)}:{" "} {t("filter.label." + filterType)}:{" "}
{filterType === "labels" ? ( {filterType === "labels" ? (
getTranslatedLabel(value) resolveLabel(value)
) : filterType === "cameras" ? ( ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} /> <CameraNameLabel camera={value} />
) : filterType === "zones" ? ( ) : filterType === "zones" ? (

View File

@ -1155,7 +1155,7 @@ function ObjectDetailsTab({
</div> </div>
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize"> <div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
{getIconForLabel(search.label, "size-4 text-primary")} {getIconForLabel(search.label, "size-4 text-primary")}
{getTranslatedLabel(search.label)} {getTranslatedLabel(search.label, search.data.type)}
{search.sub_label && ` (${search.sub_label})`} {search.sub_label && ` (${search.sub_label})`}
{isAdmin && search.end_time && ( {isAdmin && search.end_time && (
<Tooltip> <Tooltip>
@ -1394,7 +1394,9 @@ function ObjectDetailsTab({
{state == "submitted" && ( {state == "submitted" && (
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" /> <FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")} {t("explore.plus.review.state.submitted", {
ns: "components/dialog",
})}
</div> </div>
)} )}
</div> </div>

View File

@ -343,6 +343,10 @@ export function TrackingDetails({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedRecordTime]); }, [displayedRecordTime]);
const onUploadFrameToPlus = useCallback(() => {
return axios.post(`/${event.camera}/plus/${currentTime}`);
}, [event.camera, currentTime]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -388,6 +392,7 @@ export function TrackingDetails({
frigateControls={true} frigateControls={true}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime} onSeekToTime={handleSeekToTime}
onUploadFrame={onUploadFrameToPlus}
isDetailMode={true} isDetailMode={true}
camera={event.camera} camera={event.camera}
currentTimeOverride={currentTime} currentTimeOverride={currentTime}

View File

@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { usePersistence } from "@/hooks/use-persistence";
import { import {
LivePlayerError, LivePlayerError,
PlayerStatsType, PlayerStatsType,
@ -71,6 +72,8 @@ function MSEPlayer({
const [errorCount, setErrorCount] = useState<number>(0); const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0); const totalBytesLoaded = useRef(0);
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTIDRef = useRef<number | null>(null); const reconnectTIDRef = useRef<number | null>(null);
@ -475,7 +478,10 @@ function MSEPlayer({
setBufferTimeout(undefined); setBufferTimeout(undefined);
} }
const timeoutDuration = bufferTime == 0 ? 5000 : 3000; const timeoutDuration =
bufferTime == 0
? (fallbackTimeout ?? 3) * 2 * 1000
: (fallbackTimeout ?? 3) * 1000;
setBufferTimeout( setBufferTimeout(
setTimeout(() => { setTimeout(() => {
if ( if (
@ -500,6 +506,7 @@ function MSEPlayer({
onError, onError,
onPlaying, onPlaying,
playbackEnabled, playbackEnabled,
fallbackTimeout,
]); ]);
useEffect(() => { useEffect(() => {

View File

@ -349,7 +349,7 @@ function ReviewGroup({
? fetchedEvents.length ? fetchedEvents.length
: (review.data.objects ?? []).length; : (review.data.objects ?? []).length;
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`; return `${t("detail.trackedObject", { count: objectCount })}`;
}, [review, t, fetchedEvents]); }, [review, t, fetchedEvents]);
const reviewDuration = useMemo( const reviewDuration = useMemo(
@ -478,7 +478,7 @@ function ReviewGroup({
<div className="rounded-full bg-muted-foreground p-1"> <div className="rounded-full bg-muted-foreground p-1">
{getIconForLabel(audioLabel, "size-3 text-white")} {getIconForLabel(audioLabel, "size-3 text-white")}
</div> </div>
<span>{getTranslatedLabel(audioLabel)}</span> <span>{getTranslatedLabel(audioLabel, "audio")}</span>
</div> </div>
</div> </div>
))} ))}
@ -513,7 +513,8 @@ function EventList({
const isSelected = selectedObjectIds.includes(event.id); const isSelected = selectedObjectIds.includes(event.id);
const label = event.sub_label || getTranslatedLabel(event.label); const label =
event.sub_label || getTranslatedLabel(event.label, event.data.type);
const handleObjectSelect = (event: Event | undefined) => { const handleObjectSelect = (event: Event | undefined) => {
if (event) { if (event) {

View File

@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
export default function useCameraLiveMode( export default function useCameraLiveMode(
cameras: CameraConfig[], cameras: CameraConfig[],
windowVisible: boolean, windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
) { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
); );
if (isRestreamed) { if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => { if (activeStreams && activeStreams[camera.name]) {
streamNames.add(streamName); streamNames.add(activeStreams[camera.name]);
}); } else {
Object.values(camera.live.streams).forEach((streamName) => {
streamNames.add(streamName);
});
}
} }
}); });
return streamNames.size > 0 return streamNames.size > 0
? Array.from(streamNames).sort().join(",") ? Array.from(streamNames).sort().join(",")
: null; : null;
}, [cameras, config]); }, [cameras, config, activeStreams]);
const streamsFetcher = useCallback(async (key: string) => { const streamsFetcher = useCallback(async (key: string) => {
const streamNames = key.split(","); const streamNames = key.split(",");
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
[key: string]: LiveStreamMetadata; [key: string]: LiveStreamMetadata;
}>(restreamedStreamsKey, streamsFetcher, { }>(restreamedStreamsKey, streamsFetcher, {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}); });
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const [preferredLiveModes, setPreferredLiveModes] = useState<{

View File

@ -244,12 +244,12 @@ export const getDurationFromTimestamps = (
abbreviated: boolean = false, abbreviated: boolean = false,
): string => { ): string => {
if (isNaN(start_time)) { if (isNaN(start_time)) {
return "Invalid start time"; return i18n.t("time.invalidStartTime", { ns: "common" });
} }
let duration = "In Progress"; let duration = i18n.t("time.inProgress", { ns: "common" });
if (end_time !== null) { if (end_time !== null) {
if (isNaN(end_time)) { if (isNaN(end_time)) {
return "Invalid end time"; return i18n.t("time.invalidEndTime", { ns: "common" });
} }
const start = fromUnixTime(start_time); const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time); const end = fromUnixTime(end_time);

View File

@ -86,14 +86,6 @@ export default function DraggableGridLayout({
// preferred live modes per camera // preferred live modes per camera
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
} }
}, [allGroupsStreamingSettings, cameraGroup]); }, [allGroupsStreamingSettings, cameraGroup]);
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
// grid layout // grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);

View File

@ -162,6 +162,9 @@ export default function LiveCameraView({
isRestreamed ? `go2rtc/streams/${streamName}` : null, isRestreamed ? `go2rtc/streams/${streamName}` : null,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
}, },
); );
@ -1027,294 +1030,298 @@ function FrigateCameraFeatures({
disabled={!cameraEnabled || debug || isSnapshotLoading} disabled={!cameraEnabled || debug || isSnapshotLoading}
loading={isSnapshotLoading} loading={isSnapshotLoading}
/> />
<DropdownMenu modal={false}> {!fullscreen && (
<DropdownMenuTrigger> <DropdownMenu modal={false}>
<div <DropdownMenuTrigger>
className={cn( <div
"flex flex-col items-center justify-center rounded-lg bg-secondary p-2 text-secondary-foreground md:p-0", 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]`} <FaCog
/> className={`text-secondary-foreground" size-5 md:m-[6px]`}
</div> />
</DropdownMenuTrigger> </div>
<DropdownMenuContent className="max-w-96"> </DropdownMenuTrigger>
<div className="flex flex-col gap-5 p-4"> <DropdownMenuContent className="max-w-96">
{!isRestreamed && ( <div className="flex flex-col gap-5 p-4">
<div className="flex flex-col gap-2"> {!isRestreamed && (
<Label> <div className="flex flex-col gap-2">
{t("streaming.label", { ns: "components/dialog" })} <Label>
</Label> {t("streaming.label", { ns: "components/dialog" })}
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> </Label>
<LuX className="size-4 text-danger" /> <div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<div> <LuX className="size-4 text-danger" />
{t("streaming.restreaming.disabled", { <div>
ns: "components/dialog", {t("streaming.restreaming.disabled", {
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("streaming.restreaming.desc.title", {
ns: "components/dialog", ns: "components/dialog",
})} })}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<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">
{t("stream.title")}
</Label>
<Select
value={streamName}
disabled={debug}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<>
<LuX className="size-8 text-danger" />
<div>{t("stream.debug.picker")}</div>
</>
</div> </div>
)} <Popover>
<PopoverTrigger asChild>
{preferredLiveMode != "jsmpeg" && <div className="cursor-pointer p-0">
!debug && <LuInfo className="size-4" />
isRestreamed && ( <span className="sr-only">
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground"> {t("button.info", { ns: "common" })}
{supportsAudioOutput ? ( </span>
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live/#webrtc-extra-configuration",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" &&
!debug &&
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">
{t("stream.lowBandwidth.tips")}
</p>
</div> </div>
<Button </PopoverTrigger>
className={`flex items-center gap-2.5 rounded-lg`} <PopoverContent className="w-80 text-xs">
aria-label={t("stream.lowBandwidth.resetStream")} {t("streaming.restreaming.desc.title", {
variant="outline" ns: "components/dialog",
size="sm" })}
onClick={() => setLowBandwidth(false)} <div className="mt-2 flex items-center text-primary">
> <Link
<MdOutlineRestartAlt className="size-5 text-primary-variant" /> to={getLocaleDocUrl("configuration/live")}
<div className="text-primary-variant"> target="_blank"
{t("stream.lowBandwidth.resetStream")} rel="noopener noreferrer"
</div> className="inline"
</Button> >
</div> {t("readTheDocumentation", { ns: "common" })}
)} <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">
{t("stream.title")}
</Label>
<Select
value={streamName}
disabled={debug}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(camera.live.streams).map(
([stream, name]) => (
<SelectItem
key={stream}
className="cursor-pointer"
value={name}
>
{stream}
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
{debug && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<>
<LuX className="size-8 text-danger" />
<div>{t("stream.debug.picker")}</div>
</>
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode != "jsmpeg" &&
!debug &&
isRestreamed &&
supportsAudioOutput && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/live/#webrtc-extra-configuration",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
{preferredLiveMode == "jsmpeg" &&
!debug &&
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">
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</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"
>
{t("stream.playInBackground.label")}
</Label>
<Switch
className="ml-1"
id="backgroundplay"
disabled={debug}
checked={playInBackground}
onCheckedChange={(checked) =>
setPlayInBackground(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.playInBackground.tips")}
</p>
</div> </div>
)} )}
{isRestreamed && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label <Label
className="mx-0 cursor-pointer text-primary" className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay" htmlFor="showstats"
> >
{t("stream.playInBackground.label")} {t("streaming.showStats.label", {
ns: "components/dialog",
})}
</Label> </Label>
<Switch <Switch
className="ml-1" className="ml-1"
id="backgroundplay" id="showstats"
disabled={debug} disabled={debug}
checked={playInBackground} checked={showStats}
onCheckedChange={(checked) => onCheckedChange={(checked) => setShowStats(checked)}
setPlayInBackground(checked)
}
/> />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("stream.playInBackground.tips")} {t("streaming.showStats.desc", {
ns: "components/dialog",
})}
</p> </p>
</div> </div>
)} <div className="flex flex-col gap-1">
<div className="flex flex-col gap-1"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <Label
<Label className="mx-0 cursor-pointer text-primary"
className="mx-0 cursor-pointer text-primary" htmlFor="debug"
htmlFor="showstats" >
> {t("streaming.debugView", {
{t("streaming.showStats.label", { ns: "components/dialog",
ns: "components/dialog", })}
})} </Label>
</Label> <Switch
<Switch className="ml-1"
className="ml-1" id="debug"
id="showstats" checked={debug}
disabled={debug} onCheckedChange={(checked) => setDebug(checked)}
checked={showStats} />
onCheckedChange={(checked) => setShowStats(checked)} </div>
/>
</div>
<p className="text-sm text-muted-foreground">
{t("streaming.showStats.desc", {
ns: "components/dialog",
})}
</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="debug"
>
{t("streaming.debugView", {
ns: "components/dialog",
})}
</Label>
<Switch
className="ml-1"
id="debug"
checked={debug}
onCheckedChange={(checked) => setDebug(checked)}
/>
</div> </div>
</div> </div>
</div> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> )}
</> </>
); );
} }

View File

@ -202,14 +202,6 @@ export default function LiveDashboardView({
}; };
}, []); }, []);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible);
const [globalAutoLive] = usePersistence("autoLiveView", true); const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false); const [displayCameraNames] = usePersistence("displayCameraNames", false);
@ -239,6 +231,33 @@ export default function LiveDashboardView({
[visibleCameraObserver.current], [visibleCameraObserver.current],
); );
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
const handleError = useCallback( const handleError = useCallback(

View File

@ -649,7 +649,7 @@ export function RecordingView({
value="detail" value="detail"
aria-label="Detail Stream" aria-label="Detail Stream"
> >
<div className="">Detail</div> <div className="">{t("detail.label")}</div>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
) : ( ) : (

View File

@ -99,6 +99,10 @@ export default function UiSettingsView() {
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0); const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true); const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
"liveFallbackTimeout",
3,
);
return ( return (
<> <>
@ -161,6 +165,48 @@ export default function UiSettingsView() {
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p> <p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
</div> </div>
</div> </div>
<div className="space-y-3">
<div className="flex flex-row items-center justify-start gap-2">
<Label
className="cursor-pointer"
htmlFor="live-fallback-timeout"
>
{t("general.liveDashboard.liveFallbackTimeout.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
</div>
<Select
value={fallbackTimeout?.toString()}
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
>
<SelectTrigger className="w-36">
{t("time.second", {
ns: "common",
time: fallbackTimeout,
count: fallbackTimeout,
})}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
<SelectItem
key={timeout}
className="cursor-pointer"
value={timeout.toString()}
>
{t("time.second", {
ns: "common",
time: timeout,
count: timeout,
})}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="my-3 flex w-full flex-col space-y-6"> <div className="my-3 flex w-full flex-col space-y-6">